alchemy 0.3.1 → 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.
Files changed (113) hide show
  1. package/README.md +8 -3
  2. package/lib/ai/document.d.ts +12 -1
  3. package/lib/ai/document.js +10 -1
  4. package/lib/ai/index.d.ts +0 -2
  5. package/lib/ai/index.js +0 -2
  6. package/lib/alchemy.d.ts +5 -0
  7. package/lib/alchemy.js +44 -1
  8. package/lib/apply.js +17 -10
  9. package/lib/aws/account-id.d.ts +4 -1
  10. package/lib/aws/account-id.js +1 -1
  11. package/lib/aws/credentials.d.ts +5 -0
  12. package/lib/aws/credentials.js +0 -0
  13. package/lib/aws/oidc/oidc-provider.js +2 -2
  14. package/lib/aws/role.js +35 -23
  15. package/lib/cloudflare/account-api-token.d.ts +168 -0
  16. package/lib/cloudflare/account-api-token.js +179 -0
  17. package/lib/cloudflare/account-id.d.ts +1 -0
  18. package/lib/cloudflare/account-id.js +0 -0
  19. package/lib/cloudflare/api.d.ts +38 -1
  20. package/lib/cloudflare/api.js +55 -4
  21. package/lib/cloudflare/assets.d.ts +74 -0
  22. package/lib/cloudflare/assets.js +73 -0
  23. package/lib/cloudflare/bindings.d.ts +6 -1
  24. package/lib/cloudflare/bindings.js +6 -0
  25. package/lib/cloudflare/bucket.d.ts +118 -3
  26. package/lib/cloudflare/bucket.js +269 -89
  27. package/lib/cloudflare/custom-domain.d.ts +75 -0
  28. package/lib/cloudflare/custom-domain.js +154 -0
  29. package/lib/cloudflare/{dns.js → dns-records.js} +14 -1
  30. package/lib/cloudflare/index.d.ts +6 -1
  31. package/lib/cloudflare/index.js +6 -1
  32. package/lib/cloudflare/permission-groups.d.ts +78 -0
  33. package/lib/cloudflare/permission-groups.js +48 -0
  34. package/lib/cloudflare/r2-rest-state-store.d.ts +2 -1
  35. package/lib/cloudflare/r2-rest-state-store.js +55 -24
  36. package/lib/cloudflare/static-site.d.ts +17 -18
  37. package/lib/cloudflare/static-site.js +19 -10
  38. package/lib/cloudflare/worker.d.ts +14 -0
  39. package/lib/cloudflare/worker.js +150 -3
  40. package/lib/{util/encrypt.d.ts → encrypt.d.ts} +1 -1
  41. package/lib/{util/encrypt.js → encrypt.js} +1 -1
  42. package/lib/fs/file-system-state-store.js +1 -1
  43. package/lib/fs/file.d.ts +18 -0
  44. package/lib/fs/file.js +6 -0
  45. package/lib/internal/{providers.d.ts → docs/providers.d.ts} +10 -4
  46. package/lib/internal/docs/providers.js +196 -0
  47. package/lib/secret.d.ts +3 -0
  48. package/lib/secret.js +13 -0
  49. package/lib/{util/serde.d.ts → serde.d.ts} +1 -1
  50. package/lib/{util/serde.js → serde.js} +12 -4
  51. package/lib/test/bun.js +3 -2
  52. package/lib/util/sha256.d.ts +1 -0
  53. package/lib/util/sha256.js +4 -0
  54. package/lib/web/vitepress/config.d.ts +2 -1
  55. package/lib/web/vitepress/config.js +1 -0
  56. package/lib/web/vitepress/index.d.ts +1 -0
  57. package/lib/web/vitepress/index.js +1 -0
  58. package/lib/web/vitepress/process-front-matter-files.d.ts +18 -0
  59. package/lib/web/vitepress/process-front-matter-files.js +68 -0
  60. package/lib/web/vitepress/vitepress.js +3 -2
  61. package/package.json +8 -4
  62. package/src/ai/document.ts +26 -2
  63. package/src/ai/index.ts +0 -2
  64. package/src/alchemy.ts +56 -2
  65. package/src/apply.ts +21 -20
  66. package/src/aws/account-id.ts +6 -2
  67. package/src/aws/credentials.ts +6 -0
  68. package/src/aws/oidc/oidc-provider.ts +17 -17
  69. package/src/aws/role.ts +68 -54
  70. package/src/cloudflare/account-api-token.ts +365 -0
  71. package/src/cloudflare/account-id.ts +0 -0
  72. package/src/cloudflare/api.ts +88 -17
  73. package/src/cloudflare/assets.ts +155 -0
  74. package/src/cloudflare/bindings.ts +11 -2
  75. package/src/cloudflare/bucket.ts +493 -133
  76. package/src/cloudflare/custom-domain.ts +318 -0
  77. package/src/cloudflare/{dns.ts → dns-records.ts} +36 -22
  78. package/src/cloudflare/index.ts +6 -1
  79. package/src/cloudflare/permission-groups.ts +137 -0
  80. package/src/cloudflare/r2-rest-state-store.ts +87 -42
  81. package/src/cloudflare/static-site.ts +20 -32
  82. package/src/cloudflare/worker.ts +253 -5
  83. package/src/dns/import-dns.ts +4 -4
  84. package/src/{util/encrypt.ts → encrypt.ts} +7 -10
  85. package/src/fs/file-system-state-store.ts +5 -5
  86. package/src/fs/file.ts +29 -0
  87. package/src/internal/docs/providers.ts +281 -0
  88. package/src/secret.ts +16 -0
  89. package/src/{util/serde.ts → serde.ts} +16 -10
  90. package/src/test/bun.ts +8 -7
  91. package/src/util/sha256.ts +5 -0
  92. package/src/web/vitepress/config.ts +3 -1
  93. package/src/web/vitepress/index.ts +1 -0
  94. package/src/web/vitepress/process-front-matter-files.ts +98 -0
  95. package/src/web/vitepress/vitepress.ts +3 -2
  96. package/lib/ai/approve.d.ts +0 -99
  97. package/lib/ai/approve.js +0 -76
  98. package/lib/ai/review.d.ts +0 -122
  99. package/lib/ai/review.js +0 -101
  100. package/lib/internal/getting-started.d.ts +0 -21
  101. package/lib/internal/getting-started.js +0 -87
  102. package/lib/internal/index.d.ts +0 -3
  103. package/lib/internal/index.js +0 -3
  104. package/lib/internal/providers.js +0 -172
  105. package/lib/internal/tutorial.d.ts +0 -104
  106. package/lib/internal/tutorial.js +0 -251
  107. package/src/ai/approve.ts +0 -163
  108. package/src/ai/review.ts +0 -213
  109. package/src/internal/getting-started.ts +0 -115
  110. package/src/internal/index.ts +0 -3
  111. package/src/internal/providers.ts +0 -241
  112. package/src/internal/tutorial.ts +0 -392
  113. /package/lib/cloudflare/{dns.d.ts → dns-records.d.ts} +0 -0
@@ -1,8 +1,13 @@
1
+ export * from "./account-api-token";
2
+ export * from "./api";
3
+ export * from "./assets";
1
4
  export * from "./bindings";
2
5
  export * from "./bucket";
3
- export * from "./dns";
6
+ export * from "./custom-domain";
7
+ export * from "./dns-records";
4
8
  export * from "./durable-object-namespace";
5
9
  export * from "./kv-namespace";
10
+ export * from "./permission-groups";
6
11
  export * from "./r2-rest-state-store";
7
12
  export * from "./static-site";
8
13
  export * from "./worker";
@@ -0,0 +1,78 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import { type CloudflareApiOptions } from "./api";
4
+ /**
5
+ * Cloudflare permission group as returned by the API
6
+ */
7
+ export interface PermissionGroup {
8
+ /**
9
+ * Unique identifier for the permission group
10
+ */
11
+ id: string;
12
+ /**
13
+ * Human-readable name of the permission group
14
+ */
15
+ name: string;
16
+ /**
17
+ * Scopes included in this permission group
18
+ */
19
+ scopes: string[];
20
+ }
21
+ /**
22
+ * All Cloudflare permission groups mapped by name to ID
23
+ *
24
+ * @see https://developers.cloudflare.com/r2/api/tokens/#permissions
25
+ */
26
+ export type PermissionGroups = Resource<"cloudflare::PermissionGroups"> & {
27
+ /**
28
+ * Admin Read & Write - Allows create, list, delete buckets and edit bucket configurations
29
+ * plus list, write, and read object access
30
+ */
31
+ "Workers R2 Storage Write": PermissionGroup;
32
+ /**
33
+ * Admin Read only - Allows list buckets and view bucket configuration
34
+ * plus list and read object access
35
+ */
36
+ "Workers R2 Storage Read": PermissionGroup;
37
+ /**
38
+ * Object Read & Write - Allows read, write, and list objects in specific buckets
39
+ */
40
+ "Workers R2 Storage Bucket Item Write": PermissionGroup;
41
+ /**
42
+ * Object Read only - Allows read and list objects in specific buckets
43
+ */
44
+ "Workers R2 Storage Bucket Item Read": PermissionGroup;
45
+ /**
46
+ * Dynamically discovered permission groups
47
+ */
48
+ [name: string]: PermissionGroup;
49
+ };
50
+ /**
51
+ * Lists all permission groups available for the Cloudflare account
52
+ * and returns a typed map of permission names to their IDs.
53
+ *
54
+ * This is primarily used when creating API tokens for Cloudflare services like R2.
55
+ *
56
+ * @example
57
+ * // Get all permission groups including those for R2
58
+ * const permissions = await PermissionGroups("cloudflare-permissions");
59
+ *
60
+ * // Use with AccountApiToken to create a token with proper permissions
61
+ * const token = await AccountApiToken("r2-token", {
62
+ * name: "R2 Read-Only Token",
63
+ * policies: [
64
+ * {
65
+ * effect: "allow",
66
+ * resources: {
67
+ * "com.cloudflare.edge.r2.bucket.abc123_default_my-bucket": "*"
68
+ * },
69
+ * permissionGroups: [
70
+ * {
71
+ * id: permissions["Workers R2 Storage Bucket Item Read"]
72
+ * }
73
+ * ]
74
+ * }
75
+ * ]
76
+ * });
77
+ */
78
+ export declare const PermissionGroups: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<PermissionGroups>, id: string, options?: CloudflareApiOptions) => Promise<PermissionGroups>);
@@ -0,0 +1,48 @@
1
+ import { Resource } from "../resource";
2
+ import { createCloudflareApi } from "./api";
3
+ /**
4
+ * Lists all permission groups available for the Cloudflare account
5
+ * and returns a typed map of permission names to their IDs.
6
+ *
7
+ * This is primarily used when creating API tokens for Cloudflare services like R2.
8
+ *
9
+ * @example
10
+ * // Get all permission groups including those for R2
11
+ * const permissions = await PermissionGroups("cloudflare-permissions");
12
+ *
13
+ * // Use with AccountApiToken to create a token with proper permissions
14
+ * const token = await AccountApiToken("r2-token", {
15
+ * name: "R2 Read-Only Token",
16
+ * policies: [
17
+ * {
18
+ * effect: "allow",
19
+ * resources: {
20
+ * "com.cloudflare.edge.r2.bucket.abc123_default_my-bucket": "*"
21
+ * },
22
+ * permissionGroups: [
23
+ * {
24
+ * id: permissions["Workers R2 Storage Bucket Item Read"]
25
+ * }
26
+ * ]
27
+ * }
28
+ * ]
29
+ * });
30
+ */
31
+ export const PermissionGroups = Resource("cloudflare::PermissionGroups", async function (id, options) {
32
+ // Only create and update phases are supported
33
+ if (this.phase === "delete") {
34
+ return this.destroy();
35
+ }
36
+ // Initialize API client
37
+ const api = await createCloudflareApi(options);
38
+ // Fetch permission groups from Cloudflare API
39
+ const response = await api.get(`/accounts/${api.accountId}/tokens/permission_groups`);
40
+ if (!response.ok) {
41
+ throw new Error(`Failed to fetch permission groups: ${response.statusText}`);
42
+ }
43
+ const data = (await response.json());
44
+ if (!data.success || !data.result) {
45
+ throw new Error(`API returned error: ${data.errors?.[0]?.message || "Unknown error"}`);
46
+ }
47
+ return this(Object.fromEntries(data.result.map((group) => [group.name, group])));
48
+ });
@@ -1,4 +1,5 @@
1
1
  import type { Scope } from "../scope";
2
+ import type { Secret } from "../secret";
2
3
  import type { State, StateStore } from "../state";
3
4
  /**
4
5
  * Options for CloudflareR2StateStore
@@ -17,7 +18,7 @@ export interface CloudflareR2StateStoreOptions {
17
18
  /**
18
19
  * API key to use (overrides CLOUDFLARE_API_KEY env var)
19
20
  */
20
- apiKey?: string;
21
+ apiKey?: Secret;
21
22
  /**
22
23
  * Account ID to use (overrides CLOUDFLARE_ACCOUNT_ID env var)
23
24
  */
@@ -1,5 +1,5 @@
1
+ import { deserialize, serialize } from "../serde";
1
2
  import { withExponentialBackoff } from "../util/retry";
2
- import { deserialize, serialize } from "../util/serde";
3
3
  import { createCloudflareApi } from "./api";
4
4
  /**
5
5
  * State store implementation using Cloudflare R2 API
@@ -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();
@@ -198,7 +219,8 @@ export class R2RestStateStore {
198
219
  },
199
220
  // Retry on transient errors
200
221
  (error) => error.message?.includes("503") || error.message?.includes("timeout"), 5, // 5 retry attempts
201
- 1000);
222
+ 1000 // Start with 1 second delay
223
+ );
202
224
  }
203
225
  /**
204
226
  * Delete a state by key
@@ -207,13 +229,22 @@ export class R2RestStateStore {
207
229
  */
208
230
  async delete(key) {
209
231
  await this.ensureInitialized();
210
- const response = await this.api.delete(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
211
- if (!response.ok && response.status !== 404) {
212
- const errorData = await response.json().catch(() => ({
213
- errors: [{ message: response.statusText }],
214
- }));
215
- throw new Error(`Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`);
216
- }
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
+ );
217
248
  }
218
249
  /**
219
250
  * Convert key for storage by replacing slashes with colons
@@ -72,13 +72,6 @@ export interface StaticSiteProps {
72
72
  * @default true
73
73
  */
74
74
  production?: boolean;
75
- /**
76
- * Custom domain for the site
77
- */
78
- domain?: string | {
79
- name: string;
80
- redirects?: string[];
81
- };
82
75
  build?: {
83
76
  /**
84
77
  * Command to run before deploying the site
@@ -137,13 +130,6 @@ export interface StaticSite extends Resource<"cloudflare::StaticSite"> {
137
130
  * List of uploaded asset files
138
131
  */
139
132
  assets: string[];
140
- /**
141
- * Custom domain for the site
142
- */
143
- domain?: string | {
144
- name: string;
145
- redirects?: string[];
146
- };
147
133
  /**
148
134
  * Whether the site is deployed to production
149
135
  */
@@ -198,13 +184,26 @@ export interface StaticSite extends Resource<"cloudflare::StaticSite"> {
198
184
  * });
199
185
  *
200
186
  * @example
201
- * // Create a static site with custom error page and index
202
- * const customSite = await StaticSite("custom-site", {
187
+ * // Create a static site with a custom domain using the CustomDomain resource
188
+ * const site = await StaticSite("custom-site", {
203
189
  * name: "custom-site",
204
190
  * dir: "./www",
205
191
  * errorPage: "404.html",
206
- * indexPage: "home.html",
207
- * domain: "www.example.com"
192
+ * indexPage: "home.html"
193
+ * });
194
+ *
195
+ * // Then configure the custom domain separately
196
+ * const domain = await CustomDomain("custom-domain", {
197
+ * name: "www.example.com",
198
+ * zoneId: "abcdef123456789",
199
+ * workerName: site.name
200
+ * });
201
+ *
202
+ * @example
203
+ * // Create a static site with multiple domains (primary + redirects)
204
+ * const site = await StaticSite("multi-domain-site", {
205
+ * name: "multi-domain-site",
206
+ * dir: "./public"
208
207
  * });
209
208
  *
210
209
  * @see https://developers.cloudflare.com/workers/platform/sites
@@ -50,13 +50,26 @@ const __dirname = import.meta.dirname;
50
50
  * });
51
51
  *
52
52
  * @example
53
- * // Create a static site with custom error page and index
54
- * const customSite = await StaticSite("custom-site", {
53
+ * // Create a static site with a custom domain using the CustomDomain resource
54
+ * const site = await StaticSite("custom-site", {
55
55
  * name: "custom-site",
56
56
  * dir: "./www",
57
57
  * errorPage: "404.html",
58
- * indexPage: "home.html",
59
- * domain: "www.example.com"
58
+ * indexPage: "home.html"
59
+ * });
60
+ *
61
+ * // Then configure the custom domain separately
62
+ * const domain = await CustomDomain("custom-domain", {
63
+ * name: "www.example.com",
64
+ * zoneId: "abcdef123456789",
65
+ * workerName: site.name
66
+ * });
67
+ *
68
+ * @example
69
+ * // Create a static site with multiple domains (primary + redirects)
70
+ * const site = await StaticSite("multi-domain-site", {
71
+ * name: "multi-domain-site",
72
+ * dir: "./public"
60
73
  * });
61
74
  *
62
75
  * @see https://developers.cloudflare.com/workers/platform/sites
@@ -64,6 +77,8 @@ const __dirname = import.meta.dirname;
64
77
  export const StaticSite = Resource("cloudflare::StaticSite", {
65
78
  alwaysUpdate: true,
66
79
  }, async function (id, props) {
80
+ // Create Cloudflare API client with automatic account discovery
81
+ const api = await createCloudflareApi();
67
82
  if (this.phase === "delete") {
68
83
  // For delete operations, we'll rely on the Worker delete to clean up
69
84
  // Return empty output for deleted state
@@ -113,8 +128,6 @@ export const StaticSite = Resource("cloudflare::StaticSite", {
113
128
  // Use the provided name
114
129
  const siteName = props.name;
115
130
  const indexPage = props.indexPage || "index.html";
116
- // Create Cloudflare API client with automatic account discovery
117
- const api = await createCloudflareApi();
118
131
  // Step 1: Create or get the KV namespace for assets
119
132
  const [kv, assetManifest] = await Promise.all([
120
133
  KVNamespace("assets", {
@@ -122,9 +135,6 @@ export const StaticSite = Resource("cloudflare::StaticSite", {
122
135
  }),
123
136
  generateAssetManifest(props.dir),
124
137
  ]);
125
- console.log({
126
- assetManifest,
127
- });
128
138
  // Step 3: Upload assets to KV
129
139
  await uploadAssetManifest(api, kv.namespaceId, assetManifest);
130
140
  // Prepare the bindings for the worker
@@ -196,7 +206,6 @@ export const StaticSite = Resource("cloudflare::StaticSite", {
196
206
  assets: assetManifest.map((item) => item.key),
197
207
  createdAt: this.output?.createdAt || now,
198
208
  updatedAt: now,
199
- domain: props.domain,
200
209
  production: props.production !== false,
201
210
  url: worker.url,
202
211
  routes,
@@ -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
+ }
@@ -5,7 +5,7 @@
5
5
  * @param key - The encryption key
6
6
  * @returns The base64-encoded encrypted value with nonce
7
7
  */
8
- export declare function encryptWithKey(value: string, key: string): Promise<string>;
8
+ export declare function encrypt(value: string, key: string): Promise<string>;
9
9
  /**
10
10
  * Decrypt a value encrypted with a symmetric key
11
11
  *
@@ -6,7 +6,7 @@ import sodium from "libsodium-wrappers";
6
6
  * @param key - The encryption key
7
7
  * @returns The base64-encoded encrypted value with nonce
8
8
  */
9
- export async function encryptWithKey(value, key) {
9
+ export async function encrypt(value, key) {
10
10
  // Initialize libsodium
11
11
  await sodium.ready;
12
12
  // Derive a key from the passphrase
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { deserialize, serialize } from "../serde";
3
4
  import { ignore } from "../util/ignore";
4
- import { deserialize, serialize } from "../util/serde";
5
5
  const stateRootDir = path.join(process.cwd(), ".alchemy");
6
6
  export class FileSystemStateStore {
7
7
  scope;