alchemy 0.4.0 → 0.4.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/lib/cloudflare/assets.d.ts +74 -0
- package/lib/cloudflare/assets.js +73 -0
- package/lib/cloudflare/bindings.d.ts +6 -1
- package/lib/cloudflare/bindings.js +6 -0
- package/lib/cloudflare/index.d.ts +1 -0
- package/lib/cloudflare/index.js +1 -0
- package/lib/cloudflare/r2-rest-state-store.js +52 -22
- package/lib/cloudflare/worker.d.ts +14 -0
- package/lib/cloudflare/worker.js +150 -3
- package/package.json +1 -1
- package/src/cloudflare/assets.ts +155 -0
- package/src/cloudflare/bindings.ts +11 -2
- package/src/cloudflare/index.ts +1 -0
- package/src/cloudflare/r2-rest-state-store.ts +78 -34
- package/src/cloudflare/worker.ts +253 -5
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Context } from "../context";
|
|
2
|
+
import { Resource } from "../resource";
|
|
3
|
+
/**
|
|
4
|
+
* Properties for creating or updating Assets
|
|
5
|
+
*/
|
|
6
|
+
export interface AssetsProps {
|
|
7
|
+
/**
|
|
8
|
+
* Path to a directory containing static assets to be uploaded
|
|
9
|
+
* These files will be served by Cloudflare's Workers runtime
|
|
10
|
+
*/
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Output returned after Assets creation/update
|
|
15
|
+
*/
|
|
16
|
+
export interface Assets extends Resource<"cloudflare::Asset">, AssetsProps {
|
|
17
|
+
/**
|
|
18
|
+
* The type of binding
|
|
19
|
+
*/
|
|
20
|
+
type: "assets";
|
|
21
|
+
/**
|
|
22
|
+
* The ID of the assets bundle
|
|
23
|
+
*/
|
|
24
|
+
id: string;
|
|
25
|
+
/**
|
|
26
|
+
* Asset files that were found
|
|
27
|
+
*/
|
|
28
|
+
files: AssetFile[];
|
|
29
|
+
/**
|
|
30
|
+
* Time at which the assets were created
|
|
31
|
+
*/
|
|
32
|
+
createdAt: number;
|
|
33
|
+
/**
|
|
34
|
+
* Time at which the assets were last updated
|
|
35
|
+
*/
|
|
36
|
+
updatedAt: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Represents a single asset file
|
|
40
|
+
*/
|
|
41
|
+
export interface AssetFile {
|
|
42
|
+
/**
|
|
43
|
+
* Path relative to the assets directory
|
|
44
|
+
*/
|
|
45
|
+
path: string;
|
|
46
|
+
/**
|
|
47
|
+
* Full filesystem path to the file
|
|
48
|
+
*/
|
|
49
|
+
filePath: string;
|
|
50
|
+
/**
|
|
51
|
+
* Content type of the file
|
|
52
|
+
*/
|
|
53
|
+
contentType: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Cloudflare Assets represent a collection of static files that can be uploaded and served
|
|
57
|
+
* by Cloudflare Workers.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // Create a basic assets bundle from a local directory
|
|
61
|
+
* const staticAssets = await Assets("static", {
|
|
62
|
+
* path: "./src/assets"
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* // Use these assets with a worker
|
|
66
|
+
* const worker = await Worker("frontend", {
|
|
67
|
+
* name: "frontend-worker",
|
|
68
|
+
* entrypoint: "./src/worker.ts",
|
|
69
|
+
* bindings: {
|
|
70
|
+
* ASSETS: staticAssets
|
|
71
|
+
* }
|
|
72
|
+
* });
|
|
73
|
+
*/
|
|
74
|
+
export declare const Assets: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<Assets>, id: string, props: AssetsProps) => Promise<Assets>);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { Resource } from "../resource";
|
|
4
|
+
import { getContentType } from "../util/content-type";
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare Assets represent a collection of static files that can be uploaded and served
|
|
7
|
+
* by Cloudflare Workers.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Create a basic assets bundle from a local directory
|
|
11
|
+
* const staticAssets = await Assets("static", {
|
|
12
|
+
* path: "./src/assets"
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* // Use these assets with a worker
|
|
16
|
+
* const worker = await Worker("frontend", {
|
|
17
|
+
* name: "frontend-worker",
|
|
18
|
+
* entrypoint: "./src/worker.ts",
|
|
19
|
+
* bindings: {
|
|
20
|
+
* ASSETS: staticAssets
|
|
21
|
+
* }
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export const Assets = Resource("cloudflare::Asset", async function (id, props) {
|
|
25
|
+
if (this.phase === "delete") {
|
|
26
|
+
return this.destroy();
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
// Check if the assets directory exists
|
|
30
|
+
const stats = await fs.stat(props.path);
|
|
31
|
+
if (!stats.isDirectory()) {
|
|
32
|
+
throw new Error(`Assets path ${props.path} is not a directory`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new Error(`Assets directory ${props.path} does not exist or is not accessible`);
|
|
37
|
+
}
|
|
38
|
+
// Recursively get all files in the assets directory
|
|
39
|
+
const filesList = await getFilesRecursively(props.path);
|
|
40
|
+
// Create asset file objects
|
|
41
|
+
const files = filesList.map((filePath) => {
|
|
42
|
+
const relativePath = path.relative(props.path, filePath);
|
|
43
|
+
const normalizedPath = relativePath.split(path.sep).join("/"); // Ensure forward slashes for URLs
|
|
44
|
+
return {
|
|
45
|
+
path: normalizedPath,
|
|
46
|
+
filePath,
|
|
47
|
+
contentType: getContentType(filePath),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
// Get current timestamp
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
// Construct the output
|
|
53
|
+
return this({
|
|
54
|
+
id,
|
|
55
|
+
type: "assets",
|
|
56
|
+
path: props.path,
|
|
57
|
+
files,
|
|
58
|
+
createdAt: this.output?.createdAt || now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
// Helper functions for file operations
|
|
63
|
+
async function getFilesRecursively(dir) {
|
|
64
|
+
const files = await fs.readdir(dir, { withFileTypes: true });
|
|
65
|
+
const allFiles = await Promise.all(files.map(async (file) => {
|
|
66
|
+
const path = `${dir}/${file.name}`;
|
|
67
|
+
if (file.isDirectory()) {
|
|
68
|
+
return getFilesRecursively(path);
|
|
69
|
+
}
|
|
70
|
+
return path;
|
|
71
|
+
}));
|
|
72
|
+
return allFiles.flat();
|
|
73
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/
|
|
5
5
|
*/
|
|
6
6
|
import type { Secret } from "../secret";
|
|
7
|
+
import type { Assets } from "./assets";
|
|
7
8
|
import type { R2Bucket } from "./bucket";
|
|
8
9
|
import type { DurableObjectNamespace } from "./durable-object-namespace";
|
|
9
10
|
import type { KVNamespace } from "./kv-namespace";
|
|
@@ -14,8 +15,12 @@ export type Bindings = {
|
|
|
14
15
|
/**
|
|
15
16
|
* L2 Binding Resources.
|
|
16
17
|
*/
|
|
17
|
-
export type Binding = DurableObjectNamespace | KVNamespace | Worker | R2Bucket | Secret | string;
|
|
18
|
+
export type Binding = DurableObjectNamespace | KVNamespace | Worker | R2Bucket | Secret | string | Assets;
|
|
18
19
|
export declare function isDurableObjectNamespace(binding: Binding): binding is DurableObjectNamespace;
|
|
20
|
+
/**
|
|
21
|
+
* Check if a binding is an Assets resource
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAssets(binding: Binding): binding is Assets;
|
|
19
24
|
/**
|
|
20
25
|
* Union type for all Worker binding types (API spec)
|
|
21
26
|
*/
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export function isDurableObjectNamespace(binding) {
|
|
2
2
|
return (typeof binding === "object" && binding.type === "durable_object_namespace");
|
|
3
3
|
}
|
|
4
|
+
/**
|
|
5
|
+
* Check if a binding is an Assets resource
|
|
6
|
+
*/
|
|
7
|
+
export function isAssets(binding) {
|
|
8
|
+
return typeof binding === "object" && binding.type === "assets";
|
|
9
|
+
}
|
package/lib/cloudflare/index.js
CHANGED
|
@@ -74,13 +74,22 @@ export class R2RestStateStore {
|
|
|
74
74
|
params.append("cursor", cursor);
|
|
75
75
|
}
|
|
76
76
|
const listPath = `/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects?${params.toString()}`;
|
|
77
|
-
const response = await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
const response = await withExponentialBackoff(async () => {
|
|
78
|
+
const response = await this.api.get(listPath);
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const errorData = await response.json().catch(() => ({
|
|
81
|
+
errors: [{ message: response.statusText }],
|
|
82
|
+
}));
|
|
83
|
+
throw new Error(`Error listing R2 objects: ${errorData.errors?.[0]?.message || response.statusText}`);
|
|
84
|
+
}
|
|
85
|
+
return response;
|
|
86
|
+
},
|
|
87
|
+
// Retry on transient errors
|
|
88
|
+
(error) => error.message?.includes("502") ||
|
|
89
|
+
error.message?.includes("503") ||
|
|
90
|
+
error.message?.includes("timeout"), 5, // 5 retry attempts
|
|
91
|
+
1000 // Start with 1 second delay
|
|
92
|
+
);
|
|
84
93
|
const data = (await response.json());
|
|
85
94
|
// The result structure may be under "result" key in Cloudflare's API
|
|
86
95
|
const result = data.result || data;
|
|
@@ -114,15 +123,27 @@ export class R2RestStateStore {
|
|
|
114
123
|
async get(key) {
|
|
115
124
|
await this.ensureInitialized();
|
|
116
125
|
try {
|
|
117
|
-
const response = await
|
|
118
|
-
|
|
119
|
-
if (response.
|
|
120
|
-
|
|
126
|
+
const response = await withExponentialBackoff(async () => {
|
|
127
|
+
const response = await this.api.get(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
if (response.status === 404) {
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
132
|
+
const errorData = await response.json().catch(() => ({
|
|
133
|
+
errors: [{ message: response.statusText }],
|
|
134
|
+
}));
|
|
135
|
+
throw new Error(`Error getting R2 object: ${errorData.errors?.[0]?.message || response.statusText}`);
|
|
121
136
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
return response;
|
|
138
|
+
},
|
|
139
|
+
// Retry on transient errors
|
|
140
|
+
(error) => error.message?.includes("502") ||
|
|
141
|
+
error.message?.includes("503") ||
|
|
142
|
+
error.message?.includes("timeout"), 5, // 5 retry attempts
|
|
143
|
+
1000 // Start with 1 second delay
|
|
144
|
+
);
|
|
145
|
+
if (response.status === 404) {
|
|
146
|
+
return undefined;
|
|
126
147
|
}
|
|
127
148
|
// Parse and deserialize the state data
|
|
128
149
|
const rawData = await response.json();
|
|
@@ -208,13 +229,22 @@ export class R2RestStateStore {
|
|
|
208
229
|
*/
|
|
209
230
|
async delete(key) {
|
|
210
231
|
await this.ensureInitialized();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
232
|
+
await withExponentialBackoff(async () => {
|
|
233
|
+
const response = await this.api.delete(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
|
|
234
|
+
if (!response.ok && response.status !== 404) {
|
|
235
|
+
const errorData = await response.json().catch(() => ({
|
|
236
|
+
errors: [{ message: response.statusText }],
|
|
237
|
+
}));
|
|
238
|
+
throw new Error(`Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`);
|
|
239
|
+
}
|
|
240
|
+
return response;
|
|
241
|
+
},
|
|
242
|
+
// Retry on transient errors
|
|
243
|
+
(error) => error.message?.includes("502") ||
|
|
244
|
+
error.message?.includes("503") ||
|
|
245
|
+
error.message?.includes("timeout"), 5, // 5 retry attempts
|
|
246
|
+
1000 // Start with 1 second delay
|
|
247
|
+
);
|
|
218
248
|
}
|
|
219
249
|
/**
|
|
220
250
|
* Convert key for storage by replacing slashes with colons
|
|
@@ -166,6 +166,20 @@ export interface Worker<B extends Bindings = Bindings> extends Resource<"cloudfl
|
|
|
166
166
|
* }
|
|
167
167
|
* });
|
|
168
168
|
*
|
|
169
|
+
* @example
|
|
170
|
+
* // Create a worker with static assets:
|
|
171
|
+
* const staticAssets = await Assets("static", {
|
|
172
|
+
* path: "./src/assets"
|
|
173
|
+
* });
|
|
174
|
+
*
|
|
175
|
+
* const frontendWorker = await Worker("frontend", {
|
|
176
|
+
* name: "frontend-worker",
|
|
177
|
+
* entrypoint: "./src/worker.ts",
|
|
178
|
+
* bindings: {
|
|
179
|
+
* ASSETS: staticAssets
|
|
180
|
+
* }
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
169
183
|
* @see https://developers.cloudflare.com/workers/
|
|
170
184
|
*/
|
|
171
185
|
export declare const Worker: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | (<const B extends Bindings>(this: Context<Worker<NoInfer<B>>>, id: string, props: WorkerProps<B>) => Promise<Worker<B>>);
|
package/lib/cloudflare/worker.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
1
2
|
import * as fs from "fs/promises";
|
|
2
3
|
import { Bundle } from "../esbuild/bundle";
|
|
3
4
|
import { Resource } from "../resource";
|
|
4
5
|
import { isSecret } from "../secret";
|
|
6
|
+
import { getContentType } from "../util/content-type";
|
|
5
7
|
import { withExponentialBackoff } from "../util/retry";
|
|
6
8
|
import { slugify } from "../util/slugify";
|
|
7
9
|
import { createCloudflareApi } from "./api";
|
|
8
|
-
import { isDurableObjectNamespace, } from "./bindings";
|
|
10
|
+
import { isAssets, isDurableObjectNamespace, } from "./bindings";
|
|
9
11
|
import { isKVNamespace } from "./kv-namespace";
|
|
10
12
|
/**
|
|
11
13
|
* A Cloudflare Worker is a serverless function that can be deployed to the Cloudflare network.
|
|
@@ -68,6 +70,20 @@ import { isKVNamespace } from "./kv-namespace";
|
|
|
68
70
|
* }
|
|
69
71
|
* });
|
|
70
72
|
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Create a worker with static assets:
|
|
75
|
+
* const staticAssets = await Assets("static", {
|
|
76
|
+
* path: "./src/assets"
|
|
77
|
+
* });
|
|
78
|
+
*
|
|
79
|
+
* const frontendWorker = await Worker("frontend", {
|
|
80
|
+
* name: "frontend-worker",
|
|
81
|
+
* entrypoint: "./src/worker.ts",
|
|
82
|
+
* bindings: {
|
|
83
|
+
* ASSETS: staticAssets
|
|
84
|
+
* }
|
|
85
|
+
* });
|
|
86
|
+
*
|
|
71
87
|
* @see https://developers.cloudflare.com/workers/
|
|
72
88
|
*/
|
|
73
89
|
export const Worker = Resource("cloudflare::Worker", {
|
|
@@ -89,9 +105,28 @@ export const Worker = Resource("cloudflare::Worker", {
|
|
|
89
105
|
await assertWorkerDoesNotExist(this, api, workerName);
|
|
90
106
|
}
|
|
91
107
|
const oldBindings = await this.get("bindings");
|
|
92
|
-
const scriptMetadata = await prepareWorkerMetadata(this, oldBindings, props);
|
|
93
108
|
// Get the script content - either from props.script, or by bundling
|
|
94
109
|
const scriptContent = props.script ?? (await bundleWorkerScript(props));
|
|
110
|
+
// Find any assets bindings
|
|
111
|
+
const assetsBindings = [];
|
|
112
|
+
if (props.bindings) {
|
|
113
|
+
for (const [bindingName, binding] of Object.entries(props.bindings)) {
|
|
114
|
+
if (isAssets(binding)) {
|
|
115
|
+
assetsBindings.push({ name: bindingName, assets: binding });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Upload any assets and get completion tokens
|
|
120
|
+
let assetUploadResult;
|
|
121
|
+
if (assetsBindings.length > 0) {
|
|
122
|
+
// We'll use the first asset binding for now
|
|
123
|
+
// In the future, we might want to support multiple asset bindings
|
|
124
|
+
const assetBinding = assetsBindings[0];
|
|
125
|
+
// Upload the assets and get the completion token
|
|
126
|
+
assetUploadResult = await uploadAssets(api, workerName, assetBinding.assets);
|
|
127
|
+
}
|
|
128
|
+
// Prepare metadata with bindings
|
|
129
|
+
const scriptMetadata = await prepareWorkerMetadata(this, oldBindings, props, assetUploadResult);
|
|
95
130
|
// Upload the worker script
|
|
96
131
|
await putWorker(api, workerName, scriptContent, scriptMetadata);
|
|
97
132
|
// TODO: it is less than ideal that this can fail, resulting in state problem
|
|
@@ -206,7 +241,7 @@ class NotFoundError extends Error {
|
|
|
206
241
|
this.name = "NotFoundError";
|
|
207
242
|
}
|
|
208
243
|
}
|
|
209
|
-
async function prepareWorkerMetadata(ctx, oldBindings, props) {
|
|
244
|
+
async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult) {
|
|
210
245
|
// Prepare metadata with bindings
|
|
211
246
|
const meta = {
|
|
212
247
|
bindings: [],
|
|
@@ -223,6 +258,15 @@ async function prepareWorkerMetadata(ctx, oldBindings, props) {
|
|
|
223
258
|
new_sqlite_classes: props.migrations?.new_sqlite_classes ?? [],
|
|
224
259
|
},
|
|
225
260
|
};
|
|
261
|
+
// If we have asset upload results, add them to the metadata
|
|
262
|
+
if (assetUploadResult) {
|
|
263
|
+
meta.assets = {
|
|
264
|
+
jwt: assetUploadResult.completionToken,
|
|
265
|
+
};
|
|
266
|
+
if (assetUploadResult.assetConfig) {
|
|
267
|
+
meta.assets.config = assetUploadResult.assetConfig;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
226
270
|
const bindings = (props.bindings ?? {});
|
|
227
271
|
// Convert bindings to the format expected by the API
|
|
228
272
|
for (const [bindingName, binding] of Object.entries(bindings)) {
|
|
@@ -284,6 +328,12 @@ async function prepareWorkerMetadata(ctx, oldBindings, props) {
|
|
|
284
328
|
bucket_name: binding.name,
|
|
285
329
|
});
|
|
286
330
|
}
|
|
331
|
+
else if (isAssets(binding)) {
|
|
332
|
+
meta.bindings.push({
|
|
333
|
+
type: "assets",
|
|
334
|
+
name: bindingName,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
287
337
|
else if (isSecret(binding)) {
|
|
288
338
|
meta.bindings.push({
|
|
289
339
|
type: "secret_text",
|
|
@@ -468,3 +518,100 @@ async function getWorkerBindings(api, workerName, environment = "production") {
|
|
|
468
518
|
const data = await response.json();
|
|
469
519
|
return data.result;
|
|
470
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Uploads assets to Cloudflare and returns a completion token
|
|
523
|
+
*
|
|
524
|
+
* @param api CloudflareApi instance
|
|
525
|
+
* @param workerName Name of the worker
|
|
526
|
+
* @param assets Assets resource containing files to upload
|
|
527
|
+
* @returns Completion token for the assets upload
|
|
528
|
+
*/
|
|
529
|
+
async function uploadAssets(api, workerName, assets) {
|
|
530
|
+
// Generate the file manifest
|
|
531
|
+
const fileMetadata = {};
|
|
532
|
+
// Process each file in the assets
|
|
533
|
+
for (const file of assets.files) {
|
|
534
|
+
const { hash, size } = await calculateFileMetadata(file.filePath);
|
|
535
|
+
// Use the relative path as the key, ensuring it starts with a slash
|
|
536
|
+
const key = file.path.startsWith("/") ? file.path : `/${file.path}`;
|
|
537
|
+
fileMetadata[key] = { hash, size };
|
|
538
|
+
}
|
|
539
|
+
// Start the upload session
|
|
540
|
+
const uploadSessionUrl = `/accounts/${api.accountId}/workers/scripts/${workerName}/assets-upload-session`;
|
|
541
|
+
const uploadSessionResponse = await api.post(uploadSessionUrl, JSON.stringify({ manifest: fileMetadata }), {
|
|
542
|
+
headers: { "Content-Type": "application/json" },
|
|
543
|
+
});
|
|
544
|
+
if (!uploadSessionResponse.ok) {
|
|
545
|
+
throw new Error(`Failed to start assets upload session: ${uploadSessionResponse.status} ${uploadSessionResponse.statusText}`);
|
|
546
|
+
}
|
|
547
|
+
const sessionData = (await uploadSessionResponse.json());
|
|
548
|
+
// If there are no buckets, assets are already uploaded or empty
|
|
549
|
+
if (!sessionData.result.buckets || sessionData.result.buckets.length === 0) {
|
|
550
|
+
return { completionToken: sessionData.result.jwt };
|
|
551
|
+
}
|
|
552
|
+
// Upload the files in batches as specified by the API
|
|
553
|
+
let completionToken = sessionData.result.jwt;
|
|
554
|
+
const buckets = sessionData.result.buckets;
|
|
555
|
+
// Process each bucket of files
|
|
556
|
+
for (const bucket of buckets) {
|
|
557
|
+
const formData = new FormData();
|
|
558
|
+
// Add each file in the bucket to the form
|
|
559
|
+
for (const fileHash of bucket) {
|
|
560
|
+
// Find the file with this hash
|
|
561
|
+
const file = assets.files.find((f) => {
|
|
562
|
+
const filePath = f.path.startsWith("/") ? f.path : `/${f.path}`;
|
|
563
|
+
return fileMetadata[filePath]?.hash === fileHash;
|
|
564
|
+
});
|
|
565
|
+
if (!file) {
|
|
566
|
+
throw new Error(`Could not find file with hash ${fileHash}`);
|
|
567
|
+
}
|
|
568
|
+
// Read the file content
|
|
569
|
+
const fileContent = await fs.readFile(file.filePath);
|
|
570
|
+
// Convert to base64 as required by the API when using base64=true
|
|
571
|
+
const base64Content = fileContent.toString("base64");
|
|
572
|
+
// Add the file to the form with the hash as the key and set the correct content type
|
|
573
|
+
const blob = new Blob([base64Content], {
|
|
574
|
+
type: getContentType(file.filePath),
|
|
575
|
+
});
|
|
576
|
+
formData.append(fileHash, blob, fileHash);
|
|
577
|
+
}
|
|
578
|
+
// Upload this batch of files
|
|
579
|
+
const uploadResponse = await api.post(`/accounts/${api.accountId}/workers/assets/upload?base64=true`, formData, {
|
|
580
|
+
headers: {
|
|
581
|
+
Authorization: `Bearer ${completionToken}`,
|
|
582
|
+
"Content-Type": "multipart/form-data",
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
if (!uploadResponse.ok) {
|
|
586
|
+
throw new Error(`Failed to upload asset files: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
|
587
|
+
}
|
|
588
|
+
const uploadData = (await uploadResponse.json());
|
|
589
|
+
// Update the completion token for the next batch
|
|
590
|
+
if (uploadData.result.jwt) {
|
|
591
|
+
completionToken = uploadData.result.jwt;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// Return the final completion token
|
|
595
|
+
return {
|
|
596
|
+
completionToken,
|
|
597
|
+
assetConfig: {
|
|
598
|
+
html_handling: "auto-trailing-slash",
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Calculate the SHA-256 hash and size of a file
|
|
604
|
+
*
|
|
605
|
+
* @param filePath Path to the file
|
|
606
|
+
* @returns Hash (first 32 chars of SHA-256) and size of the file
|
|
607
|
+
*/
|
|
608
|
+
async function calculateFileMetadata(filePath) {
|
|
609
|
+
const hash = crypto.createHash("sha256");
|
|
610
|
+
const fileContent = await fs.readFile(filePath);
|
|
611
|
+
hash.update(fileContent);
|
|
612
|
+
const fileHash = hash.digest("hex").substring(0, 32); // First 32 chars of hash
|
|
613
|
+
return {
|
|
614
|
+
hash: fileHash,
|
|
615
|
+
size: fileContent.length,
|
|
616
|
+
};
|
|
617
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { Context } from "../context";
|
|
4
|
+
import { Resource } from "../resource";
|
|
5
|
+
import { getContentType } from "../util/content-type";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Properties for creating or updating Assets
|
|
9
|
+
*/
|
|
10
|
+
export interface AssetsProps {
|
|
11
|
+
/**
|
|
12
|
+
* Path to a directory containing static assets to be uploaded
|
|
13
|
+
* These files will be served by Cloudflare's Workers runtime
|
|
14
|
+
*/
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Output returned after Assets creation/update
|
|
20
|
+
*/
|
|
21
|
+
export interface Assets extends Resource<"cloudflare::Asset">, AssetsProps {
|
|
22
|
+
/**
|
|
23
|
+
* The type of binding
|
|
24
|
+
*/
|
|
25
|
+
type: "assets";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The ID of the assets bundle
|
|
29
|
+
*/
|
|
30
|
+
id: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Asset files that were found
|
|
34
|
+
*/
|
|
35
|
+
files: AssetFile[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Time at which the assets were created
|
|
39
|
+
*/
|
|
40
|
+
createdAt: number;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Time at which the assets were last updated
|
|
44
|
+
*/
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Represents a single asset file
|
|
50
|
+
*/
|
|
51
|
+
export interface AssetFile {
|
|
52
|
+
/**
|
|
53
|
+
* Path relative to the assets directory
|
|
54
|
+
*/
|
|
55
|
+
path: string;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Full filesystem path to the file
|
|
59
|
+
*/
|
|
60
|
+
filePath: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Content type of the file
|
|
64
|
+
*/
|
|
65
|
+
contentType: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Cloudflare Assets represent a collection of static files that can be uploaded and served
|
|
70
|
+
* by Cloudflare Workers.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* // Create a basic assets bundle from a local directory
|
|
74
|
+
* const staticAssets = await Assets("static", {
|
|
75
|
+
* path: "./src/assets"
|
|
76
|
+
* });
|
|
77
|
+
*
|
|
78
|
+
* // Use these assets with a worker
|
|
79
|
+
* const worker = await Worker("frontend", {
|
|
80
|
+
* name: "frontend-worker",
|
|
81
|
+
* entrypoint: "./src/worker.ts",
|
|
82
|
+
* bindings: {
|
|
83
|
+
* ASSETS: staticAssets
|
|
84
|
+
* }
|
|
85
|
+
* });
|
|
86
|
+
*/
|
|
87
|
+
export const Assets = Resource(
|
|
88
|
+
"cloudflare::Asset",
|
|
89
|
+
async function (
|
|
90
|
+
this: Context<Assets>,
|
|
91
|
+
id: string,
|
|
92
|
+
props: AssetsProps
|
|
93
|
+
): Promise<Assets> {
|
|
94
|
+
if (this.phase === "delete") {
|
|
95
|
+
return this.destroy();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// Check if the assets directory exists
|
|
100
|
+
const stats = await fs.stat(props.path);
|
|
101
|
+
if (!stats.isDirectory()) {
|
|
102
|
+
throw new Error(`Assets path ${props.path} is not a directory`);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Assets directory ${props.path} does not exist or is not accessible`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recursively get all files in the assets directory
|
|
111
|
+
const filesList = await getFilesRecursively(props.path);
|
|
112
|
+
|
|
113
|
+
// Create asset file objects
|
|
114
|
+
const files: AssetFile[] = filesList.map((filePath) => {
|
|
115
|
+
const relativePath = path.relative(props.path, filePath);
|
|
116
|
+
const normalizedPath = relativePath.split(path.sep).join("/"); // Ensure forward slashes for URLs
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
path: normalizedPath,
|
|
120
|
+
filePath,
|
|
121
|
+
contentType: getContentType(filePath),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Get current timestamp
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
|
|
128
|
+
// Construct the output
|
|
129
|
+
return this({
|
|
130
|
+
id,
|
|
131
|
+
type: "assets",
|
|
132
|
+
path: props.path,
|
|
133
|
+
files,
|
|
134
|
+
createdAt: this.output?.createdAt || now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Helper functions for file operations
|
|
141
|
+
async function getFilesRecursively(dir: string): Promise<string[]> {
|
|
142
|
+
const files = await fs.readdir(dir, { withFileTypes: true });
|
|
143
|
+
|
|
144
|
+
const allFiles = await Promise.all(
|
|
145
|
+
files.map(async (file) => {
|
|
146
|
+
const path = `${dir}/${file.name}`;
|
|
147
|
+
if (file.isDirectory()) {
|
|
148
|
+
return getFilesRecursively(path);
|
|
149
|
+
}
|
|
150
|
+
return path;
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return allFiles.flat();
|
|
155
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/
|
|
5
5
|
*/
|
|
6
6
|
import type { Secret } from "../secret";
|
|
7
|
+
import type { Assets } from "./assets";
|
|
7
8
|
import type { R2Bucket } from "./bucket";
|
|
8
9
|
import type { DurableObjectNamespace } from "./durable-object-namespace";
|
|
9
10
|
import type { KVNamespace } from "./kv-namespace";
|
|
@@ -22,16 +23,24 @@ export type Binding =
|
|
|
22
23
|
| Worker
|
|
23
24
|
| R2Bucket
|
|
24
25
|
| Secret
|
|
25
|
-
| string
|
|
26
|
+
| string
|
|
27
|
+
| Assets;
|
|
26
28
|
|
|
27
29
|
export function isDurableObjectNamespace(
|
|
28
|
-
binding: Binding
|
|
30
|
+
binding: Binding
|
|
29
31
|
): binding is DurableObjectNamespace {
|
|
30
32
|
return (
|
|
31
33
|
typeof binding === "object" && binding.type === "durable_object_namespace"
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Check if a binding is an Assets resource
|
|
39
|
+
*/
|
|
40
|
+
export function isAssets(binding: Binding): binding is Assets {
|
|
41
|
+
return typeof binding === "object" && binding.type === "assets";
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* Union type for all Worker binding types (API spec)
|
|
37
46
|
*/
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -123,16 +123,30 @@ export class R2RestStateStore implements StateStore {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const listPath = `/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects?${params.toString()}`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
126
|
+
|
|
127
|
+
const response = await withExponentialBackoff(
|
|
128
|
+
async () => {
|
|
129
|
+
const response = await this.api.get(listPath);
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const errorData: any = await response.json().catch(() => ({
|
|
133
|
+
errors: [{ message: response.statusText }],
|
|
134
|
+
}));
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Error listing R2 objects: ${errorData.errors?.[0]?.message || response.statusText}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return response;
|
|
141
|
+
},
|
|
142
|
+
// Retry on transient errors
|
|
143
|
+
(error) =>
|
|
144
|
+
error.message?.includes("502") ||
|
|
145
|
+
error.message?.includes("503") ||
|
|
146
|
+
error.message?.includes("timeout"),
|
|
147
|
+
5, // 5 retry attempts
|
|
148
|
+
1000 // Start with 1 second delay
|
|
149
|
+
);
|
|
136
150
|
|
|
137
151
|
const data = (await response.json()) as any;
|
|
138
152
|
|
|
@@ -176,21 +190,38 @@ export class R2RestStateStore implements StateStore {
|
|
|
176
190
|
await this.ensureInitialized();
|
|
177
191
|
|
|
178
192
|
try {
|
|
179
|
-
const response = await
|
|
180
|
-
|
|
181
|
-
|
|
193
|
+
const response = await withExponentialBackoff(
|
|
194
|
+
async () => {
|
|
195
|
+
const response = await this.api.get(
|
|
196
|
+
`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`
|
|
197
|
+
);
|
|
182
198
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
if (response.status === 404) {
|
|
201
|
+
return response;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const errorData: any = await response.json().catch(() => ({
|
|
205
|
+
errors: [{ message: response.statusText }],
|
|
206
|
+
}));
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Error getting R2 object: ${errorData.errors?.[0]?.message || response.statusText}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
187
211
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
212
|
+
return response;
|
|
213
|
+
},
|
|
214
|
+
// Retry on transient errors
|
|
215
|
+
(error) =>
|
|
216
|
+
error.message?.includes("502") ||
|
|
217
|
+
error.message?.includes("503") ||
|
|
218
|
+
error.message?.includes("timeout"),
|
|
219
|
+
5, // 5 retry attempts
|
|
220
|
+
1000 // Start with 1 second delay
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (response.status === 404) {
|
|
224
|
+
return undefined;
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
// Parse and deserialize the state data
|
|
@@ -298,18 +329,31 @@ export class R2RestStateStore implements StateStore {
|
|
|
298
329
|
async delete(key: string): Promise<void> {
|
|
299
330
|
await this.ensureInitialized();
|
|
300
331
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
332
|
+
await withExponentialBackoff(
|
|
333
|
+
async () => {
|
|
334
|
+
const response = await this.api.delete(
|
|
335
|
+
`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`
|
|
336
|
+
);
|
|
304
337
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
338
|
+
if (!response.ok && response.status !== 404) {
|
|
339
|
+
const errorData: any = await response.json().catch(() => ({
|
|
340
|
+
errors: [{ message: response.statusText }],
|
|
341
|
+
}));
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return response;
|
|
348
|
+
},
|
|
349
|
+
// Retry on transient errors
|
|
350
|
+
(error) =>
|
|
351
|
+
error.message?.includes("502") ||
|
|
352
|
+
error.message?.includes("503") ||
|
|
353
|
+
error.message?.includes("timeout"),
|
|
354
|
+
5, // 5 retry attempts
|
|
355
|
+
1000 // Start with 1 second delay
|
|
356
|
+
);
|
|
313
357
|
}
|
|
314
358
|
|
|
315
359
|
/**
|
package/src/cloudflare/worker.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
1
2
|
import * as fs from "fs/promises";
|
|
2
3
|
import type { Context } from "../context";
|
|
3
4
|
import { Bundle, type BundleProps } from "../esbuild/bundle";
|
|
4
5
|
import { Resource } from "../resource";
|
|
5
6
|
import { isSecret } from "../secret";
|
|
7
|
+
import { getContentType } from "../util/content-type";
|
|
6
8
|
import { withExponentialBackoff } from "../util/retry";
|
|
7
9
|
import { slugify } from "../util/slugify";
|
|
8
10
|
import { type CloudflareApi, createCloudflareApi } from "./api";
|
|
11
|
+
import type { Assets } from "./assets";
|
|
9
12
|
import {
|
|
10
13
|
type Bindings,
|
|
11
14
|
type WorkerBindingSpec,
|
|
15
|
+
isAssets,
|
|
12
16
|
isDurableObjectNamespace,
|
|
13
17
|
} from "./bindings";
|
|
14
18
|
import type { Bound } from "./bound";
|
|
@@ -200,6 +204,20 @@ export interface Worker<B extends Bindings = Bindings>
|
|
|
200
204
|
* }
|
|
201
205
|
* });
|
|
202
206
|
*
|
|
207
|
+
* @example
|
|
208
|
+
* // Create a worker with static assets:
|
|
209
|
+
* const staticAssets = await Assets("static", {
|
|
210
|
+
* path: "./src/assets"
|
|
211
|
+
* });
|
|
212
|
+
*
|
|
213
|
+
* const frontendWorker = await Worker("frontend", {
|
|
214
|
+
* name: "frontend-worker",
|
|
215
|
+
* entrypoint: "./src/worker.ts",
|
|
216
|
+
* bindings: {
|
|
217
|
+
* ASSETS: staticAssets
|
|
218
|
+
* }
|
|
219
|
+
* });
|
|
220
|
+
*
|
|
203
221
|
* @see https://developers.cloudflare.com/workers/
|
|
204
222
|
*/
|
|
205
223
|
export const Worker = Resource(
|
|
@@ -232,15 +250,42 @@ export const Worker = Resource(
|
|
|
232
250
|
|
|
233
251
|
const oldBindings = await this.get<Bindings>("bindings");
|
|
234
252
|
|
|
253
|
+
// Get the script content - either from props.script, or by bundling
|
|
254
|
+
const scriptContent = props.script ?? (await bundleWorkerScript(props));
|
|
255
|
+
|
|
256
|
+
// Find any assets bindings
|
|
257
|
+
const assetsBindings: { name: string; assets: Assets }[] = [];
|
|
258
|
+
if (props.bindings) {
|
|
259
|
+
for (const [bindingName, binding] of Object.entries(props.bindings)) {
|
|
260
|
+
if (isAssets(binding)) {
|
|
261
|
+
assetsBindings.push({ name: bindingName, assets: binding });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Upload any assets and get completion tokens
|
|
267
|
+
let assetUploadResult: AssetUploadResult | undefined;
|
|
268
|
+
if (assetsBindings.length > 0) {
|
|
269
|
+
// We'll use the first asset binding for now
|
|
270
|
+
// In the future, we might want to support multiple asset bindings
|
|
271
|
+
const assetBinding = assetsBindings[0];
|
|
272
|
+
|
|
273
|
+
// Upload the assets and get the completion token
|
|
274
|
+
assetUploadResult = await uploadAssets(
|
|
275
|
+
api,
|
|
276
|
+
workerName,
|
|
277
|
+
assetBinding.assets
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Prepare metadata with bindings
|
|
235
282
|
const scriptMetadata = await prepareWorkerMetadata(
|
|
236
283
|
this,
|
|
237
284
|
oldBindings,
|
|
238
|
-
props
|
|
285
|
+
props,
|
|
286
|
+
assetUploadResult
|
|
239
287
|
);
|
|
240
288
|
|
|
241
|
-
// Get the script content - either from props.script, or by bundling
|
|
242
|
-
const scriptContent = props.script ?? (await bundleWorkerScript(props));
|
|
243
|
-
|
|
244
289
|
// Upload the worker script
|
|
245
290
|
await putWorker(api, workerName, scriptContent, scriptMetadata);
|
|
246
291
|
|
|
@@ -437,12 +482,29 @@ interface WorkerMetadata {
|
|
|
437
482
|
main_module?: string;
|
|
438
483
|
body_part?: string;
|
|
439
484
|
tags?: string[];
|
|
485
|
+
assets?: {
|
|
486
|
+
jwt?: string;
|
|
487
|
+
keep_assets?: boolean;
|
|
488
|
+
config?: {
|
|
489
|
+
html_handling?: "auto-trailing-slash" | "none";
|
|
490
|
+
not_found_handling?: "none" | "fall-through";
|
|
491
|
+
};
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
interface AssetUploadResult {
|
|
496
|
+
completionToken: string;
|
|
497
|
+
assetConfig?: {
|
|
498
|
+
html_handling?: "auto-trailing-slash" | "none";
|
|
499
|
+
not_found_handling?: "none" | "fall-through";
|
|
500
|
+
};
|
|
440
501
|
}
|
|
441
502
|
|
|
442
503
|
async function prepareWorkerMetadata<B extends Bindings>(
|
|
443
504
|
ctx: Context<Worker<B>>,
|
|
444
505
|
oldBindings: Bindings | undefined,
|
|
445
|
-
props: WorkerProps
|
|
506
|
+
props: WorkerProps,
|
|
507
|
+
assetUploadResult?: AssetUploadResult
|
|
446
508
|
): Promise<WorkerMetadata> {
|
|
447
509
|
// Prepare metadata with bindings
|
|
448
510
|
const meta: WorkerMetadata = {
|
|
@@ -461,6 +523,17 @@ async function prepareWorkerMetadata<B extends Bindings>(
|
|
|
461
523
|
},
|
|
462
524
|
};
|
|
463
525
|
|
|
526
|
+
// If we have asset upload results, add them to the metadata
|
|
527
|
+
if (assetUploadResult) {
|
|
528
|
+
meta.assets = {
|
|
529
|
+
jwt: assetUploadResult.completionToken,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
if (assetUploadResult.assetConfig) {
|
|
533
|
+
meta.assets.config = assetUploadResult.assetConfig;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
464
537
|
const bindings = (props.bindings ?? {}) as Bindings;
|
|
465
538
|
|
|
466
539
|
// Convert bindings to the format expected by the API
|
|
@@ -522,6 +595,11 @@ async function prepareWorkerMetadata<B extends Bindings>(
|
|
|
522
595
|
name: bindingName,
|
|
523
596
|
bucket_name: binding.name,
|
|
524
597
|
});
|
|
598
|
+
} else if (isAssets(binding)) {
|
|
599
|
+
meta.bindings.push({
|
|
600
|
+
type: "assets",
|
|
601
|
+
name: bindingName,
|
|
602
|
+
});
|
|
525
603
|
} else if (isSecret(binding)) {
|
|
526
604
|
meta.bindings.push({
|
|
527
605
|
type: "secret_text",
|
|
@@ -802,3 +880,173 @@ async function getWorkerBindings(
|
|
|
802
880
|
|
|
803
881
|
return data.result;
|
|
804
882
|
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Interface for a file's metadata to be uploaded
|
|
886
|
+
*/
|
|
887
|
+
interface FileMetadata {
|
|
888
|
+
hash: string;
|
|
889
|
+
size: number;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Response from the assets upload session API
|
|
894
|
+
*/
|
|
895
|
+
interface UploadSessionResponse {
|
|
896
|
+
result: {
|
|
897
|
+
jwt: string;
|
|
898
|
+
buckets: string[][];
|
|
899
|
+
};
|
|
900
|
+
success: boolean;
|
|
901
|
+
errors: any[];
|
|
902
|
+
messages: any[];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Response from the file upload API
|
|
907
|
+
*/
|
|
908
|
+
interface UploadResponse {
|
|
909
|
+
result: {
|
|
910
|
+
jwt: string;
|
|
911
|
+
buckets?: string[][];
|
|
912
|
+
};
|
|
913
|
+
success: boolean;
|
|
914
|
+
errors: any[];
|
|
915
|
+
messages: any[];
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Uploads assets to Cloudflare and returns a completion token
|
|
920
|
+
*
|
|
921
|
+
* @param api CloudflareApi instance
|
|
922
|
+
* @param workerName Name of the worker
|
|
923
|
+
* @param assets Assets resource containing files to upload
|
|
924
|
+
* @returns Completion token for the assets upload
|
|
925
|
+
*/
|
|
926
|
+
async function uploadAssets(
|
|
927
|
+
api: CloudflareApi,
|
|
928
|
+
workerName: string,
|
|
929
|
+
assets: Assets
|
|
930
|
+
): Promise<AssetUploadResult> {
|
|
931
|
+
// Generate the file manifest
|
|
932
|
+
const fileMetadata: Record<string, FileMetadata> = {};
|
|
933
|
+
|
|
934
|
+
// Process each file in the assets
|
|
935
|
+
for (const file of assets.files) {
|
|
936
|
+
const { hash, size } = await calculateFileMetadata(file.filePath);
|
|
937
|
+
// Use the relative path as the key, ensuring it starts with a slash
|
|
938
|
+
const key = file.path.startsWith("/") ? file.path : `/${file.path}`;
|
|
939
|
+
fileMetadata[key] = { hash, size };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Start the upload session
|
|
943
|
+
const uploadSessionUrl = `/accounts/${api.accountId}/workers/scripts/${workerName}/assets-upload-session`;
|
|
944
|
+
const uploadSessionResponse = await api.post(
|
|
945
|
+
uploadSessionUrl,
|
|
946
|
+
JSON.stringify({ manifest: fileMetadata }),
|
|
947
|
+
{
|
|
948
|
+
headers: { "Content-Type": "application/json" },
|
|
949
|
+
}
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
if (!uploadSessionResponse.ok) {
|
|
953
|
+
throw new Error(
|
|
954
|
+
`Failed to start assets upload session: ${uploadSessionResponse.status} ${uploadSessionResponse.statusText}`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const sessionData =
|
|
959
|
+
(await uploadSessionResponse.json()) as UploadSessionResponse;
|
|
960
|
+
|
|
961
|
+
// If there are no buckets, assets are already uploaded or empty
|
|
962
|
+
if (!sessionData.result.buckets || sessionData.result.buckets.length === 0) {
|
|
963
|
+
return { completionToken: sessionData.result.jwt };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Upload the files in batches as specified by the API
|
|
967
|
+
let completionToken = sessionData.result.jwt;
|
|
968
|
+
const buckets = sessionData.result.buckets;
|
|
969
|
+
|
|
970
|
+
// Process each bucket of files
|
|
971
|
+
for (const bucket of buckets) {
|
|
972
|
+
const formData = new FormData();
|
|
973
|
+
|
|
974
|
+
// Add each file in the bucket to the form
|
|
975
|
+
for (const fileHash of bucket) {
|
|
976
|
+
// Find the file with this hash
|
|
977
|
+
const file = assets.files.find((f) => {
|
|
978
|
+
const filePath = f.path.startsWith("/") ? f.path : `/${f.path}`;
|
|
979
|
+
return fileMetadata[filePath]?.hash === fileHash;
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
if (!file) {
|
|
983
|
+
throw new Error(`Could not find file with hash ${fileHash}`);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Read the file content
|
|
987
|
+
const fileContent = await fs.readFile(file.filePath);
|
|
988
|
+
|
|
989
|
+
// Convert to base64 as required by the API when using base64=true
|
|
990
|
+
const base64Content = fileContent.toString("base64");
|
|
991
|
+
|
|
992
|
+
// Add the file to the form with the hash as the key and set the correct content type
|
|
993
|
+
const blob = new Blob([base64Content], {
|
|
994
|
+
type: getContentType(file.filePath),
|
|
995
|
+
});
|
|
996
|
+
formData.append(fileHash, blob, fileHash);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Upload this batch of files
|
|
1000
|
+
const uploadResponse = await api.post(
|
|
1001
|
+
`/accounts/${api.accountId}/workers/assets/upload?base64=true`,
|
|
1002
|
+
formData,
|
|
1003
|
+
{
|
|
1004
|
+
headers: {
|
|
1005
|
+
Authorization: `Bearer ${completionToken}`,
|
|
1006
|
+
"Content-Type": "multipart/form-data",
|
|
1007
|
+
},
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
if (!uploadResponse.ok) {
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
`Failed to upload asset files: ${uploadResponse.status} ${uploadResponse.statusText}`
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const uploadData = (await uploadResponse.json()) as UploadResponse;
|
|
1018
|
+
// Update the completion token for the next batch
|
|
1019
|
+
if (uploadData.result.jwt) {
|
|
1020
|
+
completionToken = uploadData.result.jwt;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Return the final completion token
|
|
1025
|
+
return {
|
|
1026
|
+
completionToken,
|
|
1027
|
+
assetConfig: {
|
|
1028
|
+
html_handling: "auto-trailing-slash",
|
|
1029
|
+
},
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Calculate the SHA-256 hash and size of a file
|
|
1035
|
+
*
|
|
1036
|
+
* @param filePath Path to the file
|
|
1037
|
+
* @returns Hash (first 32 chars of SHA-256) and size of the file
|
|
1038
|
+
*/
|
|
1039
|
+
async function calculateFileMetadata(
|
|
1040
|
+
filePath: string
|
|
1041
|
+
): Promise<{ hash: string; size: number }> {
|
|
1042
|
+
const hash = crypto.createHash("sha256");
|
|
1043
|
+
const fileContent = await fs.readFile(filePath);
|
|
1044
|
+
|
|
1045
|
+
hash.update(fileContent);
|
|
1046
|
+
const fileHash = hash.digest("hex").substring(0, 32); // First 32 chars of hash
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
hash: fileHash,
|
|
1050
|
+
size: fileContent.length,
|
|
1051
|
+
};
|
|
1052
|
+
}
|