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.
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./account-api-token";
2
2
  export * from "./api";
3
+ export * from "./assets";
3
4
  export * from "./bindings";
4
5
  export * from "./bucket";
5
6
  export * from "./custom-domain";
@@ -1,5 +1,6 @@
1
1
  export * from "./account-api-token";
2
2
  export * from "./api";
3
+ export * from "./assets";
3
4
  export * from "./bindings";
4
5
  export * from "./bucket";
5
6
  export * from "./custom-domain";
@@ -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 this.api.get(listPath);
78
- if (!response.ok) {
79
- const errorData = await response.json().catch(() => ({
80
- errors: [{ message: response.statusText }],
81
- }));
82
- throw new Error(`Error listing R2 objects: ${errorData.errors?.[0]?.message || response.statusText}`);
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 this.api.get(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
118
- if (!response.ok) {
119
- if (response.status === 404) {
120
- return undefined;
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
- const errorData = await response.json().catch(() => ({
123
- errors: [{ message: response.statusText }],
124
- }));
125
- throw new Error(`Error getting R2 object: ${errorData.errors?.[0]?.message || response.statusText}`);
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
- const response = await this.api.delete(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
212
- if (!response.ok && response.status !== 404) {
213
- const errorData = await response.json().catch(() => ({
214
- errors: [{ message: response.statusText }],
215
- }));
216
- throw new Error(`Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`);
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>>);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "module": "./lib/index.js",
6
6
  "scripts": {
@@ -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
  */
@@ -1,5 +1,6 @@
1
1
  export * from "./account-api-token";
2
2
  export * from "./api";
3
+ export * from "./assets";
3
4
  export * from "./bindings";
4
5
  export * from "./bucket";
5
6
  export * from "./custom-domain";
@@ -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
- const response = await this.api.get(listPath);
127
-
128
- if (!response.ok) {
129
- const errorData: any = await response.json().catch(() => ({
130
- errors: [{ message: response.statusText }],
131
- }));
132
- throw new Error(
133
- `Error listing R2 objects: ${errorData.errors?.[0]?.message || response.statusText}`
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 this.api.get(
180
- `/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`
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
- if (!response.ok) {
184
- if (response.status === 404) {
185
- return undefined;
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
- const errorData: any = await response.json().catch(() => ({
189
- errors: [{ message: response.statusText }],
190
- }));
191
- throw new Error(
192
- `Error getting R2 object: ${errorData.errors?.[0]?.message || response.statusText}`
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
- const response = await this.api.delete(
302
- `/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`
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
- if (!response.ok && response.status !== 404) {
306
- const errorData: any = await response.json().catch(() => ({
307
- errors: [{ message: response.statusText }],
308
- }));
309
- throw new Error(
310
- `Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`
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
  /**
@@ -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
+ }