alchemy 0.1.12 → 0.1.14

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 (168) hide show
  1. package/README.md +12 -2
  2. package/lib/$.d.ts +12 -0
  3. package/lib/$.d.ts.map +1 -0
  4. package/lib/$.js +42 -0
  5. package/lib/$.js.map +1 -0
  6. package/lib/alchemize.d.ts.map +1 -1
  7. package/lib/alchemize.js +7 -0
  8. package/lib/alchemize.js.map +1 -1
  9. package/lib/apply.d.ts +2 -2
  10. package/lib/apply.d.ts.map +1 -1
  11. package/lib/apply.js +1 -1
  12. package/lib/apply.js.map +1 -1
  13. package/lib/aws/account-id.d.ts +5 -0
  14. package/lib/aws/account-id.d.ts.map +1 -0
  15. package/lib/aws/account-id.js +10 -0
  16. package/lib/aws/account-id.js.map +1 -0
  17. package/lib/aws/index.d.ts +1 -0
  18. package/lib/aws/index.d.ts.map +1 -1
  19. package/lib/aws/index.js +1 -0
  20. package/lib/aws/index.js.map +1 -1
  21. package/lib/aws/oidc/github-oidc-provider.d.ts +11 -0
  22. package/lib/aws/oidc/github-oidc-provider.d.ts.map +1 -0
  23. package/lib/aws/oidc/github-oidc-provider.js +22 -0
  24. package/lib/aws/oidc/github-oidc-provider.js.map +1 -0
  25. package/lib/aws/oidc/index.d.ts +3 -0
  26. package/lib/aws/oidc/index.d.ts.map +1 -0
  27. package/lib/aws/oidc/index.js +3 -0
  28. package/lib/aws/oidc/index.js.map +1 -0
  29. package/lib/aws/oidc/oidc-provider.d.ts +60 -0
  30. package/lib/aws/oidc/oidc-provider.d.ts.map +1 -0
  31. package/lib/aws/oidc/oidc-provider.js +140 -0
  32. package/lib/aws/oidc/oidc-provider.js.map +1 -0
  33. package/lib/aws/queue.d.ts +24 -0
  34. package/lib/aws/queue.d.ts.map +1 -1
  35. package/lib/aws/queue.js +6 -1
  36. package/lib/aws/queue.js.map +1 -1
  37. package/lib/aws/role.d.ts.map +1 -1
  38. package/lib/aws/role.js +49 -1
  39. package/lib/aws/role.js.map +1 -1
  40. package/lib/aws/ses.d.ts.map +1 -1
  41. package/lib/aws/ses.js +20 -28
  42. package/lib/aws/ses.js.map +1 -1
  43. package/lib/aws/table.d.ts.map +1 -1
  44. package/lib/aws/table.js +95 -73
  45. package/lib/aws/table.js.map +1 -1
  46. package/lib/cloudflare/api.d.ts +4 -0
  47. package/lib/cloudflare/api.d.ts.map +1 -1
  48. package/lib/cloudflare/api.js +6 -0
  49. package/lib/cloudflare/api.js.map +1 -1
  50. package/lib/cloudflare/bindings.d.ts +19 -4
  51. package/lib/cloudflare/bindings.d.ts.map +1 -1
  52. package/lib/cloudflare/bindings.js +0 -5
  53. package/lib/cloudflare/bindings.js.map +1 -1
  54. package/lib/cloudflare/durable-object-namespace.d.ts +20 -0
  55. package/lib/cloudflare/durable-object-namespace.d.ts.map +1 -0
  56. package/lib/cloudflare/durable-object-namespace.js +21 -0
  57. package/lib/cloudflare/durable-object-namespace.js.map +1 -0
  58. package/lib/cloudflare/index.d.ts +1 -0
  59. package/lib/cloudflare/index.d.ts.map +1 -1
  60. package/lib/cloudflare/index.js +1 -0
  61. package/lib/cloudflare/index.js.map +1 -1
  62. package/lib/cloudflare/kv-namespace.d.ts +3 -0
  63. package/lib/cloudflare/kv-namespace.d.ts.map +1 -1
  64. package/lib/cloudflare/kv-namespace.js +5 -1
  65. package/lib/cloudflare/kv-namespace.js.map +1 -1
  66. package/lib/cloudflare/static-site-router.d.ts +11 -2
  67. package/lib/cloudflare/static-site-router.d.ts.map +1 -1
  68. package/lib/cloudflare/static-site-router.js +98 -76
  69. package/lib/cloudflare/static-site-router.js.map +1 -1
  70. package/lib/cloudflare/static-site.d.ts +15 -15
  71. package/lib/cloudflare/static-site.d.ts.map +1 -1
  72. package/lib/cloudflare/static-site.js +45 -47
  73. package/lib/cloudflare/static-site.js.map +1 -1
  74. package/lib/cloudflare/worker-metadata.d.ts +130 -0
  75. package/lib/cloudflare/worker-metadata.d.ts.map +1 -0
  76. package/lib/cloudflare/worker-metadata.js +2 -0
  77. package/lib/cloudflare/worker-metadata.js.map +1 -0
  78. package/lib/cloudflare/worker-migration.d.ts +110 -0
  79. package/lib/cloudflare/worker-migration.d.ts.map +1 -0
  80. package/lib/cloudflare/worker-migration.js +11 -0
  81. package/lib/cloudflare/worker-migration.js.map +1 -0
  82. package/lib/cloudflare/worker.d.ts +34 -13
  83. package/lib/cloudflare/worker.d.ts.map +1 -1
  84. package/lib/cloudflare/worker.js +341 -321
  85. package/lib/cloudflare/worker.js.map +1 -1
  86. package/lib/destroy.d.ts +1 -1
  87. package/lib/destroy.d.ts.map +1 -1
  88. package/lib/destroy.js +2 -1
  89. package/lib/destroy.js.map +1 -1
  90. package/lib/esbuild.d.ts +1 -1
  91. package/lib/esbuild.d.ts.map +1 -1
  92. package/lib/esbuild.js +3 -1
  93. package/lib/esbuild.js.map +1 -1
  94. package/lib/github/client.d.ts +34 -0
  95. package/lib/github/client.d.ts.map +1 -0
  96. package/lib/github/client.js +91 -0
  97. package/lib/github/client.js.map +1 -0
  98. package/lib/github/index.d.ts +2 -0
  99. package/lib/github/index.d.ts.map +1 -0
  100. package/lib/github/index.js +2 -0
  101. package/lib/github/index.js.map +1 -0
  102. package/lib/github/secret.d.ts +46 -0
  103. package/lib/github/secret.d.ts.map +1 -0
  104. package/lib/github/secret.js +103 -0
  105. package/lib/github/secret.js.map +1 -0
  106. package/lib/global.d.ts +0 -2
  107. package/lib/global.d.ts.map +1 -1
  108. package/lib/global.js +0 -2
  109. package/lib/global.js.map +1 -1
  110. package/lib/index.d.ts +2 -0
  111. package/lib/index.d.ts.map +1 -1
  112. package/lib/index.js +2 -0
  113. package/lib/index.js.map +1 -1
  114. package/lib/output.d.ts +4 -3
  115. package/lib/output.d.ts.map +1 -1
  116. package/lib/output.js +10 -3
  117. package/lib/output.js.map +1 -1
  118. package/lib/print.d.ts +7 -0
  119. package/lib/print.d.ts.map +1 -0
  120. package/lib/print.js +10 -0
  121. package/lib/print.js.map +1 -0
  122. package/lib/resource.d.ts +8 -4
  123. package/lib/resource.d.ts.map +1 -1
  124. package/lib/resource.js +125 -91
  125. package/lib/resource.js.map +1 -1
  126. package/lib/scope.d.ts +3 -0
  127. package/lib/scope.d.ts.map +1 -1
  128. package/lib/scope.js +4 -12
  129. package/lib/scope.js.map +1 -1
  130. package/lib/utils/retry.d.ts +13 -0
  131. package/lib/utils/retry.d.ts.map +1 -0
  132. package/lib/utils/retry.js +31 -0
  133. package/lib/utils/retry.js.map +1 -0
  134. package/package.json +12 -3
  135. package/src/$.ts +69 -0
  136. package/src/alchemize.ts +12 -0
  137. package/src/apply.ts +4 -4
  138. package/src/aws/account-id.ts +11 -0
  139. package/src/aws/index.ts +1 -0
  140. package/src/aws/oidc/github-oidc-provider.ts +34 -0
  141. package/src/aws/oidc/index.ts +2 -0
  142. package/src/aws/oidc/oidc-provider.ts +253 -0
  143. package/src/aws/queue.ts +23 -1
  144. package/src/aws/role.ts +69 -0
  145. package/src/aws/ses.ts +27 -31
  146. package/src/aws/table.ts +113 -82
  147. package/src/cloudflare/api.ts +6 -0
  148. package/src/cloudflare/bindings.ts +43 -26
  149. package/src/cloudflare/durable-object-namespace.ts +37 -0
  150. package/src/cloudflare/index.ts +1 -0
  151. package/src/cloudflare/kv-namespace.ts +12 -1
  152. package/src/cloudflare/static-site-router.ts +124 -82
  153. package/src/cloudflare/static-site.ts +66 -69
  154. package/src/cloudflare/worker-metadata.ts +154 -0
  155. package/src/cloudflare/worker-migration.ts +145 -0
  156. package/src/cloudflare/worker.ts +557 -429
  157. package/src/destroy.ts +2 -2
  158. package/src/esbuild.ts +3 -0
  159. package/src/github/client.ts +115 -0
  160. package/src/github/index.ts +1 -0
  161. package/src/github/secret.ts +172 -0
  162. package/src/global.ts +0 -3
  163. package/src/index.ts +2 -1
  164. package/src/output.ts +24 -8
  165. package/src/print.ts +18 -0
  166. package/src/resource.ts +156 -115
  167. package/src/scope.ts +7 -12
  168. package/src/utils/retry.ts +36 -0
@@ -1,10 +1,15 @@
1
1
  import * as fs from "fs/promises";
2
2
  import { apply } from "../apply";
3
3
  import { Bundle, type BundleProps } from "../esbuild";
4
+ import type { Resolved } from "../output";
4
5
  import { type Context, Resource } from "../resource";
5
- import { createCloudflareApi } from "./api";
6
- import type { WorkerBinding } from "./bindings";
7
-
6
+ import { type CloudflareApi, createCloudflareApi } from "./api";
7
+ import type { Bindings, WorkerBindingSpec } from "./bindings";
8
+ import type { DurableObjectNamespace } from "./durable-object-namespace";
9
+ import { isDurableObjectNamespace } from "./durable-object-namespace";
10
+ import { isKVNamespace } from "./kv-namespace";
11
+ import type { WorkerScriptMetadata } from "./worker-metadata";
12
+ import type { SingleStepMigration } from "./worker-migration";
8
13
  /**
9
14
  * Properties for creating or updating a Worker
10
15
  */
@@ -22,17 +27,11 @@ export interface WorkerProps {
22
27
  */
23
28
  entrypoint?: string;
24
29
 
25
- /**
26
- * Reference to a pre-configured Bundle resource
27
- * One of script, entryPoint, or bundle must be provided
28
- */
29
- bundle?: Bundle;
30
-
31
30
  /**
32
31
  * Bundle options when using entryPoint
33
32
  * Ignored if bundle is provided
34
33
  */
35
- bundleOptions?: Omit<BundleProps, "entryPoint">;
34
+ bundle?: Omit<BundleProps, "entryPoint">;
36
35
 
37
36
  /**
38
37
  * Module format for the worker script
@@ -57,13 +56,15 @@ export interface WorkerProps {
57
56
  /**
58
57
  * Bindings to attach to the worker
59
58
  */
60
- bindings?: WorkerBinding[];
59
+ bindings?: Bindings;
61
60
 
62
61
  /**
63
- * Whether the worker should be deployed to production
64
- * @default true
62
+ * Environment variables to attach to the worker
63
+ * These will be converted to plain_text bindings
65
64
  */
66
- production?: boolean;
65
+ env?: {
66
+ [key: string]: string;
67
+ };
67
68
 
68
69
  /**
69
70
  * Whether to enable a workers.dev URL for this worker
@@ -71,12 +72,32 @@ export interface WorkerProps {
71
72
  * @default false
72
73
  */
73
74
  url?: boolean;
75
+
76
+ /**
77
+ * Observability configuration for the worker
78
+ * Controls whether worker logs are enabled
79
+ * @default { enabled: true }
80
+ */
81
+ observability?: {
82
+ /**
83
+ * Whether to enable worker logs
84
+ * @default true
85
+ */
86
+ enabled?: boolean;
87
+ };
88
+
89
+ /**
90
+ * Migrations to apply to the worker
91
+ */
92
+ migrations?: SingleStepMigration;
74
93
  }
75
94
 
76
95
  /**
77
96
  * Output returned after Worker creation/update
78
97
  */
79
98
  export interface WorkerOutput extends Omit<WorkerProps, "url"> {
99
+ type: "service";
100
+
80
101
  /**
81
102
  * The ID of the worker
82
103
  */
@@ -97,472 +118,579 @@ export interface WorkerOutput extends Omit<WorkerProps, "url"> {
97
118
  * Format: {name}.{subdomain}.workers.dev
98
119
  */
99
120
  url?: string;
121
+
122
+ /**
123
+ * The bindings that were created
124
+ */
125
+ bindings: Bindings;
126
+ }
127
+
128
+ export function isWorker(resource: any): resource is Resolved<Worker> {
129
+ return resource && typeof resource === "object" && resource.type === "worker";
100
130
  }
101
131
 
102
132
  export class Worker extends Resource(
103
133
  "cloudflare::Worker",
134
+ {
135
+ alwaysUpdate: true,
136
+ },
104
137
  async (ctx: Context<WorkerOutput>, props: WorkerProps) => {
105
138
  // Create Cloudflare API client with automatic account discovery
106
139
  const api = await createCloudflareApi();
107
140
 
108
- // Validate that a name is provided
109
- if (!props.name) {
110
- throw new Error("Worker name is required - must be explicitly specified");
111
- }
112
-
113
141
  // Use the provided name
114
142
  const workerName = props.name;
115
143
 
116
144
  // Validate input - we need either script, entryPoint, or bundle
117
- if (!props.script && !props.entrypoint && !props.bundle) {
118
- throw new Error("One of script, entryPoint, or bundle must be provided");
119
- }
120
-
121
- // For create operations, check if a worker with the same name already exists
122
- if (ctx.event === "create") {
123
- const checkExistsResponse = await api.get(
124
- `/accounts/${api.accountId}/workers/scripts/${workerName}`,
125
- );
126
-
127
- if (checkExistsResponse.ok) {
128
- throw new Error(
129
- `Worker with name '${workerName}' already exists. Please use a unique name.`,
130
- );
131
- } else if (checkExistsResponse.status !== 404) {
132
- // If it's neither a success nor a 404, it's an unexpected error
133
- const errorData: any = await checkExistsResponse.json().catch(() => ({
134
- errors: [{ message: checkExistsResponse.statusText }],
135
- }));
136
- throw new Error(
137
- `Error checking if worker exists: ${errorData.errors?.[0]?.message || checkExistsResponse.statusText}`,
138
- );
139
- }
140
- // If it's a 404, that means the worker doesn't exist yet, which is what we want
145
+ if (!props.script && !props.entrypoint) {
146
+ throw new Error("One of script or entryPoint must be provided");
141
147
  }
142
148
 
143
149
  if (ctx.event === "delete") {
144
- // Delete worker
145
- const deleteResponse = await api.delete(
146
- `/accounts/${api.accountId}/workers/scripts/${workerName}`,
147
- );
150
+ return deleteWorker(ctx, api, workerName);
151
+ } else if (ctx.event === "create") {
152
+ await assertWorkerDoesNotExist(ctx, api, workerName);
153
+ }
148
154
 
149
- // Check for success (2xx status code)
150
- if (!deleteResponse.ok && deleteResponse.status !== 404) {
151
- const errorData: any = await deleteResponse
152
- .json()
153
- .catch(() => ({ errors: [{ message: deleteResponse.statusText }] }));
154
- console.error(
155
- "Error deleting worker:",
156
- errorData.errors?.[0]?.message || deleteResponse.statusText,
157
- );
158
- }
155
+ const oldBindings = await ctx.get<Bindings>("bindings");
156
+
157
+ const scriptMetadata = await prepareWorkerMetadata(ctx, oldBindings, props);
158
+
159
+ // Get the script content - either from props.script, or by bundling
160
+ const scriptContent =
161
+ props.script ?? (await bundleWorkerScript(ctx, props));
162
+
163
+ // Upload the worker script
164
+ await putWorker(ctx, api, workerName, scriptContent, scriptMetadata);
165
+
166
+ // TODO: it is less than ideal that this can fail, resulting in state problem
167
+ await ctx.set("bindings", props.bindings);
168
+
169
+ // Handle routes if requested
170
+ await setupRoutes(api, workerName, props.routes || []);
171
+
172
+ // Handle worker URL if requested
173
+ const workerUrl = await configureURL(
174
+ ctx,
175
+ api,
176
+ workerName,
177
+ props.url ?? false,
178
+ );
179
+
180
+ // Get current timestamp
181
+ const now = Date.now();
182
+
183
+ // Construct the output
184
+ const output: WorkerOutput = {
185
+ type: "service",
186
+ id: workerName,
187
+ name: workerName,
188
+ script: scriptContent,
189
+ format: props.format || "esm", // Include format in the output
190
+ routes: props.routes || [],
191
+ bindings: props.bindings ?? {},
192
+ env: props.env,
193
+ observability: scriptMetadata.observability,
194
+ createdAt: now,
195
+ updatedAt: now,
196
+ url: workerUrl,
197
+ };
198
+
199
+ return output;
200
+ },
201
+ ) {}
159
202
 
160
- // Also remove any associated routes if they exist
161
- if (ctx.output?.routes && ctx.output.routes.length > 0 && api.zoneId) {
162
- // First get existing routes to find their IDs
163
- const routesResponse = await api.get(
164
- `/zones/${api.zoneId}/workers/routes`,
203
+ async function deleteWorker(
204
+ ctx: Context<WorkerOutput>,
205
+ api: CloudflareApi,
206
+ workerName: string,
207
+ ) {
208
+ // Delete worker
209
+ const deleteResponse = await api.delete(
210
+ `/accounts/${api.accountId}/workers/scripts/${workerName}`,
211
+ );
212
+
213
+ // Check for success (2xx status code)
214
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
215
+ const errorData: any = await deleteResponse
216
+ .json()
217
+ .catch(() => ({ errors: [{ message: deleteResponse.statusText }] }));
218
+ console.error(
219
+ "Error deleting worker:",
220
+ errorData.errors?.[0]?.message || deleteResponse.statusText,
221
+ );
222
+ }
223
+
224
+ // Also remove any associated routes if they exist
225
+ if (ctx.output?.routes && ctx.output.routes.length > 0 && api.zoneId) {
226
+ // First get existing routes to find their IDs
227
+ const routesResponse = await api.get(`/zones/${api.zoneId}/workers/routes`);
228
+
229
+ if (!routesResponse.ok) {
230
+ throw new Error(
231
+ `Could not fetch routes for cleanup: ${routesResponse.status} ${routesResponse.statusText}`,
232
+ );
233
+ }
234
+ const routesData: any = await routesResponse.json();
235
+ const existingRoutes = routesData.result || [];
236
+
237
+ for (const route of existingRoutes) {
238
+ if (ctx.output.routes.includes(route.pattern)) {
239
+ // Delete the route
240
+ const routeDeleteResponse = await api.delete(
241
+ `/zones/${api.zoneId}/workers/routes/${route.id}`,
165
242
  );
166
243
 
167
- if (routesResponse.ok) {
168
- const routesData: any = await routesResponse.json();
169
- const existingRoutes = routesData.result || [];
170
-
171
- for (const route of existingRoutes) {
172
- if (ctx.output.routes.includes(route.pattern)) {
173
- // Delete the route
174
- const routeDeleteResponse = await api.delete(
175
- `/zones/${api.zoneId}/workers/routes/${route.id}`,
176
- );
177
-
178
- if (!routeDeleteResponse.ok) {
179
- console.warn(
180
- `Failed to delete route ${route.pattern}: ${routeDeleteResponse.status} ${routeDeleteResponse.statusText}`,
181
- );
182
- }
183
- }
184
- }
185
- } else {
244
+ if (!routeDeleteResponse.ok) {
186
245
  console.warn(
187
- "Could not fetch routes for cleanup:",
188
- routesResponse.status,
189
- routesResponse.statusText,
246
+ `Failed to delete route ${route.pattern}: ${routeDeleteResponse.status} ${routeDeleteResponse.statusText}`,
190
247
  );
191
248
  }
192
249
  }
250
+ }
251
+ }
252
+
253
+ // Disable the URL if it was enabled
254
+ if (ctx.output?.url) {
255
+ try {
256
+ await api.post(
257
+ `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
258
+ JSON.stringify({ enabled: false }),
259
+ {
260
+ headers: { "Content-Type": "application/json" },
261
+ },
262
+ );
263
+ } catch (error) {
264
+ console.warn("Failed to disable worker URL during deletion:", error);
265
+ }
266
+ }
193
267
 
194
- // Disable the URL if it was enabled
195
- if (ctx.output?.url) {
196
- try {
197
- await api.post(
198
- `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
199
- JSON.stringify({ enabled: false }),
200
- {
201
- headers: { "Content-Type": "application/json" },
202
- },
203
- );
204
- } catch (error) {
205
- console.warn("Failed to disable worker URL during deletion:", error);
206
- }
207
- }
208
-
209
- // Return minimal output for deleted state
210
- return {
211
- ...props,
212
- id: "",
213
- createdAt: 0,
214
- updatedAt: 0,
215
- url: undefined,
216
- };
217
- } else {
218
- // Get the script content - either from props.script, or by bundling
219
- let scriptContent: string;
220
-
221
- if (props.script) {
222
- // Use the provided script
223
- scriptContent = props.script;
224
- } else if (props.bundle) {
225
- // Use a pre-configured Bundle resource
226
- const bundleOutput = await apply(props.bundle);
227
- scriptContent = await fs.readFile(bundleOutput.path, "utf-8");
228
- } else if (props.entrypoint) {
229
- // Create and use a Bundle resource with worker-optimized configuration
230
- const defaultBundleOptions: Omit<BundleProps, "entryPoint"> = {
231
- format: props.format === "cjs" ? "cjs" : "esm", // Use the specified format or default to ESM
232
- target: "es2020",
233
- platform: "browser",
234
- minify: true,
235
- options: {
236
- keepNames: true, // Important for Durable Object classes
237
- },
238
- };
239
-
240
- // Merge with user-provided options
241
- const bundleOptions = {
242
- ...defaultBundleOptions,
243
- ...(props.bundleOptions || {}),
244
- };
245
-
246
- // Create the bundle
247
- const bundle = new Bundle("bundle", {
248
- entryPoint: props.entrypoint,
249
- ...bundleOptions,
250
- });
268
+ // Return minimal output for deleted state
269
+ return;
270
+ }
251
271
 
252
- // Get the bundled content
253
- const bundleOutput = await apply(bundle);
254
- try {
255
- scriptContent = await fs.readFile(bundleOutput.path, "utf-8");
256
- } catch (error) {
257
- console.error("Error reading bundle:", error);
258
- throw new Error("Error reading bundle");
259
- }
260
- } else {
261
- throw new Error("No script source provided"); // Shouldn't reach here due to earlier validation
262
- }
263
- // Prepare metadata with bindings
264
- const metadata: any = {
265
- bindings: [],
266
- };
272
+ async function putWorker(
273
+ ctx: Context<WorkerOutput>,
274
+ api: CloudflareApi,
275
+ workerName: string,
276
+ scriptContent: string,
277
+ scriptMetadata: WorkerMetadata,
278
+ ) {
279
+ const scriptName = scriptMetadata.main_module ?? scriptMetadata.body_part!;
280
+
281
+ // Create FormData for the upload
282
+ const formData = new FormData();
283
+
284
+ // Add the actual script content as a named file part
285
+ formData.append(
286
+ scriptName,
287
+ new Blob([scriptContent], {
288
+ type: scriptMetadata.main_module
289
+ ? "application/javascript+module"
290
+ : "application/javascript",
291
+ }),
292
+ scriptName,
293
+ );
294
+
295
+ // console.log("Script Metadata:", JSON.stringify(scriptMetadata, null, 2));
296
+
297
+ // Add metadata as JSON
298
+ formData.append(
299
+ "metadata",
300
+ new Blob([JSON.stringify(scriptMetadata)], {
301
+ type: "application/json",
302
+ }),
303
+ );
304
+
305
+ // Upload worker script with bindings
306
+ const uploadResponse = await api.put(
307
+ `/accounts/${api.accountId}/workers/scripts/${workerName}`,
308
+ formData,
309
+ {
310
+ headers: {
311
+ "Content-Type": "multipart/form-data",
312
+ },
313
+ },
314
+ );
315
+
316
+ // Check if the upload was successful
317
+ if (!uploadResponse.ok) {
318
+ const errorData: any = await uploadResponse
319
+ .json()
320
+ .catch(() => ({ errors: [{ message: uploadResponse.statusText }] }));
321
+ throw new Error(
322
+ `Error uploading worker script '${workerName}': ${errorData.errors?.[0]?.message || uploadResponse.statusText}`,
323
+ );
324
+ }
325
+
326
+ return formData;
327
+ }
267
328
 
268
- // Convert bindings to the format expected by the API
269
- if (props.bindings && props.bindings.length > 0) {
270
- for (const binding of props.bindings) {
271
- const bindingData: any = {
272
- name: binding.name,
273
- type: binding.type,
274
- };
275
-
276
- // Map binding properties to the API format based on binding type
277
- switch (binding.type) {
278
- case "kv_namespace":
279
- bindingData.namespace_id = (binding as any).namespace_id;
280
- break;
281
- case "durable_object_namespace":
282
- bindingData.class_name = (binding as any).class_name;
283
- if ((binding as any).namespace_id) {
284
- bindingData.namespace_id = (binding as any).namespace_id;
285
- }
286
- if ((binding as any).script_name) {
287
- bindingData.script_name = (binding as any).script_name;
288
- }
289
- if ((binding as any).environment) {
290
- bindingData.environment = (binding as any).environment;
291
- }
292
- break;
293
- case "secret_text":
294
- bindingData.text = (binding as any).text;
295
- break;
296
- case "r2_bucket":
297
- bindingData.bucket_name = (binding as any).bucket_name;
298
- break;
299
- case "analytics_engine":
300
- bindingData.dataset = (binding as any).dataset;
301
- break;
302
- case "service":
303
- bindingData.service = (binding as any).service;
304
- if ((binding as any).environment) {
305
- bindingData.environment = (binding as any).environment;
306
- }
307
- if ((binding as any).namespace) {
308
- bindingData.namespace = (binding as any).namespace;
309
- }
310
- break;
311
- case "d1":
312
- bindingData.id = (binding as any).id;
313
- break;
314
- case "queue":
315
- bindingData.queue_name = (binding as any).queue_name;
316
- break;
317
- case "json":
318
- bindingData.json = (binding as any).json;
319
- break;
320
- case "hyperdrive":
321
- bindingData.id = (binding as any).id;
322
- break;
323
- case "vectorize":
324
- bindingData.index_name = (binding as any).index_name;
325
- break;
326
- case "dispatch_namespace":
327
- bindingData.namespace = (binding as any).namespace;
328
- if ((binding as any).outbound) {
329
- bindingData.outbound = (binding as any).outbound;
330
- }
331
- break;
332
- case "mtls_certificate":
333
- bindingData.certificate_id = (binding as any).certificate_id;
334
- break;
335
- case "plain_text":
336
- bindingData.text = (binding as any).text;
337
- break;
338
- case "wasm_module":
339
- bindingData.module = (binding as any).module;
340
- break;
341
- case "tail_consumer":
342
- bindingData.service = (binding as any).service;
343
- break;
344
- }
329
+ interface WorkerMetadata {
330
+ bindings: WorkerBindingSpec[];
331
+ observability: {
332
+ enabled: boolean;
333
+ };
334
+ migrations?: SingleStepMigration;
335
+ main_module?: string;
336
+ body_part?: string;
337
+ tags?: string[];
338
+ }
345
339
 
346
- metadata.bindings.push(bindingData);
340
+ async function prepareWorkerMetadata(
341
+ ctx: Context<WorkerOutput>,
342
+ oldBindings: Bindings | undefined,
343
+ props: WorkerProps,
344
+ ): Promise<WorkerMetadata> {
345
+ // Prepare metadata with bindings
346
+ const meta: WorkerMetadata = {
347
+ bindings: [],
348
+ observability: {
349
+ enabled: props.observability?.enabled !== false,
350
+ },
351
+ tags: [`alchemy:id:${ctx.resourceFQN}`],
352
+ migrations: {
353
+ new_classes: props.migrations?.new_classes ?? [],
354
+ deleted_classes: props.migrations?.deleted_classes ?? [],
355
+ renamed_classes: props.migrations?.renamed_classes ?? [],
356
+ transferred_classes: props.migrations?.transferred_classes ?? [],
357
+ new_sqlite_classes: props.migrations?.new_sqlite_classes ?? [],
358
+ },
359
+ };
360
+
361
+ const bindings = (props.bindings ?? {}) as Resolved<Bindings>;
362
+
363
+ // Convert bindings to the format expected by the API
364
+ for (const [bindingName, binding] of Object.entries(bindings)) {
365
+ // Create a copy of the binding to avoid modifying the original
366
+
367
+ if (isKVNamespace(binding)) {
368
+ meta.bindings.push({
369
+ type: "kv_namespace",
370
+ name: bindingName,
371
+ namespace_id: binding.id,
372
+ });
373
+ } else if (typeof binding === "string") {
374
+ meta.bindings.push({
375
+ type: "plain_text",
376
+ name: bindingName,
377
+ text: binding,
378
+ });
379
+ } else if (binding.type === "service") {
380
+ meta.bindings.push({
381
+ type: "service",
382
+ name: bindingName,
383
+ service: binding.id,
384
+ });
385
+ } else if (isDurableObjectNamespace(binding)) {
386
+ const stableId = binding.id;
387
+ const className = binding.className;
388
+
389
+ meta.bindings.push({
390
+ type: "durable_object_namespace",
391
+ name: bindingName,
392
+ class_name: className,
393
+ script_name: binding.scriptName,
394
+ environment: binding.environment,
395
+ namespace_id: binding.namespaceId,
396
+ });
397
+
398
+ const oldBinding: DurableObjectNamespace | undefined = Object.values(
399
+ oldBindings ?? {},
400
+ )
401
+ ?.filter(isDurableObjectNamespace)
402
+ ?.find((b) => b.id === stableId);
403
+
404
+ if (!oldBinding) {
405
+ if (binding.sqlite) {
406
+ meta.migrations!.new_sqlite_classes!.push(className);
407
+ } else {
408
+ meta.migrations!.new_classes!.push(className);
347
409
  }
410
+ } else if (oldBinding.className !== className) {
411
+ meta.migrations!.renamed_classes!.push({
412
+ from: oldBinding.className,
413
+ to: className,
414
+ });
348
415
  }
416
+ } else {
417
+ // @ts-expect-error - we should never reach here
418
+ throw new Error(`Unsupported binding type: ${binding.type}`);
419
+ }
420
+ }
421
+
422
+ // Convert env variables to plain_text bindings
423
+ if (props.env) {
424
+ for (const [key, value] of Object.entries(props.env)) {
425
+ meta.bindings.push({
426
+ name: key,
427
+ type: "plain_text",
428
+ text: value,
429
+ });
430
+ }
431
+ }
432
+
433
+ // Determine if we're using ESM or service worker format
434
+ const isEsModule = props.format !== "cjs"; // Default to ESM unless CJS is specified
435
+ const scriptName = isEsModule ? "worker.js" : "script";
436
+
437
+ if (isEsModule) {
438
+ // For ES modules format
439
+ meta.main_module = scriptName;
440
+ } else {
441
+ // For service worker format (CJS)
442
+ meta.body_part = scriptName;
443
+ }
444
+ return meta;
445
+ }
349
446
 
350
- // Create FormData for the upload
351
- const formData = new FormData();
352
-
353
- // Determine if we're using ESM or service worker format
354
- const isEsModule = props.format !== "cjs"; // Default to ESM unless CJS is specified
355
- const scriptName = isEsModule ? "worker.js" : "script";
356
- const contentType = isEsModule
357
- ? "application/javascript+module"
358
- : "application/javascript";
359
-
360
- // Prepare metadata
361
- const updatedMetadata = {
362
- ...metadata,
363
- };
364
-
365
- // Add Durable Object migrations if there are any DO bindings
366
- const durableObjectBindings = props.bindings?.filter(
367
- (binding) =>
368
- binding.type === "durable_object_namespace" &&
369
- // Only include self-referencing DOs (ones defined in this script)
370
- (binding as any).script_name === props.name,
447
+ async function assertWorkerDoesNotExist(
448
+ ctx: Context<WorkerOutput>,
449
+ api: CloudflareApi,
450
+ workerName: string,
451
+ ) {
452
+ const response = await api.get(
453
+ `/accounts/${api.accountId}/workers/scripts/${workerName}`,
454
+ );
455
+ if (response.status === 404) {
456
+ return true;
457
+ }
458
+ if (response.status === 200) {
459
+ const metadata = await getWorkerScriptMetadata(api, workerName);
460
+
461
+ if (!metadata) {
462
+ throw new Error(
463
+ `Worker exists but failed to fetch metadata: ${response.status} ${response.statusText}`,
371
464
  );
465
+ }
372
466
 
373
- if (durableObjectBindings && durableObjectBindings.length > 0) {
374
- // Add migrations metadata for Durable Objects
375
- updatedMetadata.migrations = {
376
- new_classes: durableObjectBindings.map(
377
- (binding) => (binding as any).class_name, // Just the class name as a string
378
- ),
379
- deleted_classes: [],
380
- renamed_classes: [],
381
- // Version 2 migrations
382
- new_sqlite_classes: [],
383
- updated_sqlite_classes: [],
384
- renamed_sqlite_classes: [],
385
- };
386
- }
467
+ if (
468
+ metadata.default_environment?.script.tags.includes(
469
+ `alchemy:id:${ctx.resourceFQN}`,
470
+ )
471
+ ) {
472
+ return true;
473
+ }
387
474
 
388
- if (isEsModule) {
389
- // For ES modules format
390
- updatedMetadata.main_module = scriptName;
391
- } else {
392
- // For service worker format (CJS)
393
- updatedMetadata.body_part = scriptName;
394
- }
475
+ throw new Error(
476
+ `Worker with name '${workerName}' already exists. Please use a unique name.`,
477
+ );
478
+ } else {
479
+ throw new Error(
480
+ `Error checking if worker exists: ${response.status} ${response.statusText}`,
481
+ );
482
+ }
483
+ }
395
484
 
396
- // Add the actual script content as a named file part
397
- formData.append(
398
- scriptName,
399
- new Blob([scriptContent], { type: contentType }),
400
- scriptName,
401
- );
485
+ async function bundleWorkerScript(
486
+ ctx: Context<WorkerOutput>,
487
+ props: WorkerProps,
488
+ ) {
489
+ // Get the script content - either from props.script, or by bundling
490
+
491
+ // Create and use a Bundle resource with worker-optimized configuration
492
+ const defaultBundleOptions: Omit<BundleProps, "entryPoint"> = {
493
+ format: props.format === "cjs" ? "cjs" : "esm", // Use the specified format or default to ESM
494
+ target: "es2020",
495
+ platform: "browser",
496
+ minify: true,
497
+ options: {
498
+ keepNames: true, // Important for Durable Object classes
499
+ },
500
+ };
501
+
502
+ // Merge with user-provided options
503
+ const bundleOptions = {
504
+ ...defaultBundleOptions,
505
+ ...(props.bundle || {}),
506
+ };
507
+
508
+ // Create the bundle
509
+ const bundle = new Bundle("bundle", {
510
+ entryPoint: props.entrypoint!,
511
+ ...bundleOptions,
512
+ external: [...(bundleOptions.external ?? []), "cloudflare:workers"],
513
+ });
514
+
515
+ // Get the bundled content
516
+ const bundleOutput = await apply(bundle, {
517
+ quiet: ctx.quiet,
518
+ });
519
+ try {
520
+ return await fs.readFile(bundleOutput.path, "utf-8");
521
+ } catch (error) {
522
+ console.error("Error reading bundle:", error);
523
+ throw new Error("Error reading bundle");
524
+ }
525
+ }
402
526
 
403
- // Add metadata as JSON
404
- formData.append(
405
- "metadata",
406
- new Blob([JSON.stringify(updatedMetadata)], {
407
- type: "application/json",
408
- }),
527
+ async function setupRoutes(
528
+ api: CloudflareApi,
529
+ workerName: string,
530
+ routes: string[],
531
+ ) {
532
+ // Set up routes if provided
533
+ if (routes && routes.length > 0 && api.zoneId) {
534
+ // First get existing routes
535
+ const routesResponse = await api.get(`/zones/${api.zoneId}/workers/routes`);
536
+
537
+ if (!routesResponse.ok) {
538
+ throw new Error(
539
+ `Could not fetch routes: ${routesResponse.status} ${routesResponse.statusText}`,
409
540
  );
541
+ }
542
+ const routesData: any = await routesResponse.json();
543
+ const existingRoutes = routesData.result || [];
410
544
 
411
- // Upload worker script with bindings
412
- const uploadResponse = await api.put(
413
- `/accounts/${api.accountId}/workers/scripts/${workerName}`,
414
- formData,
545
+ // For each desired route
546
+ for (const pattern of routes) {
547
+ const existingRoute = existingRoutes.find(
548
+ (r: any) => r.pattern === pattern,
415
549
  );
416
550
 
417
- // Check if the upload was successful
418
- if (!uploadResponse.ok) {
419
- const errorData: any = await uploadResponse
420
- .json()
421
- .catch(() => ({ errors: [{ message: uploadResponse.statusText }] }));
422
- throw new Error(
423
- `Error uploading worker script: ${errorData.errors?.[0]?.message || uploadResponse.statusText}`,
424
- );
425
- }
426
-
427
- // Set up routes if provided
428
- if (props.routes && props.routes.length > 0 && api.zoneId) {
429
- // First get existing routes
430
- const routesResponse = await api.get(
431
- `/zones/${api.zoneId}/workers/routes`,
432
- );
433
-
434
- if (routesResponse.ok) {
435
- const routesData: any = await routesResponse.json();
436
- const existingRoutes = routesData.result || [];
437
-
438
- // For each desired route
439
- for (const pattern of props.routes) {
440
- const existingRoute = existingRoutes.find(
441
- (r: any) => r.pattern === pattern,
442
- );
443
-
444
- if (existingRoute) {
445
- // Update if script name doesn't match
446
- if (existingRoute.script !== workerName) {
447
- const updateRouteResponse = await api.put(
448
- `/zones/${api.zoneId}/workers/routes/${existingRoute.id}`,
449
- {
450
- pattern,
451
- script: workerName,
452
- },
453
- );
454
-
455
- if (!updateRouteResponse.ok) {
456
- console.warn(
457
- `Failed to update route ${pattern}: ${updateRouteResponse.status} ${updateRouteResponse.statusText}`,
458
- );
459
- }
460
- }
461
- } else {
462
- // Create new route
463
- const createRouteResponse = await api.post(
464
- `/zones/${api.zoneId}/workers/routes`,
465
- {
466
- pattern,
467
- script: workerName,
468
- },
469
- );
470
-
471
- if (!createRouteResponse.ok) {
472
- console.warn(
473
- `Failed to create route ${pattern}: ${createRouteResponse.status} ${createRouteResponse.statusText}`,
474
- );
475
- }
476
- }
477
- }
478
- } else {
479
- console.warn(
480
- "Could not fetch routes:",
481
- routesResponse.status,
482
- routesResponse.statusText,
483
- );
484
- }
485
- }
486
-
487
- // Handle worker URL if requested
488
- let workerUrl;
489
- if (props.url) {
490
- try {
491
- // Enable the workers.dev subdomain for this worker
492
- await api.post(
493
- `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
494
- { enabled: true, previews_enabled: true },
551
+ if (existingRoute) {
552
+ // Update if script name doesn't match
553
+ if (existingRoute.script !== workerName) {
554
+ const updateRouteResponse = await api.put(
555
+ `/zones/${api.zoneId}/workers/routes/${existingRoute.id}`,
495
556
  {
496
- headers: { "Content-Type": "application/json" },
557
+ pattern,
558
+ script: workerName,
497
559
  },
498
560
  );
499
561
 
500
- // Get the account's workers.dev subdomain
501
- const subdomainResponse = await api.get(
502
- `/accounts/${api.accountId}/workers/subdomain`,
503
- );
504
-
505
- if (subdomainResponse.ok) {
506
- const subdomainData: {
507
- result: {
508
- subdomain: string;
509
- };
510
- } = await subdomainResponse.json();
511
- const subdomain = subdomainData.result?.subdomain;
512
-
513
- if (subdomain) {
514
- workerUrl = `${workerName}.${subdomain}.workers.dev`;
515
-
516
- // Add a delay when the subdomain is first created.
517
- // This is to prevent an issue where a negative cache-hit
518
- // causes the subdomain to be unavailable for 30 seconds.
519
- if (ctx.event === "create" || !ctx.output?.url) {
520
- await new Promise((resolve) => setTimeout(resolve, 3000));
521
- }
522
- }
523
- } else {
562
+ if (!updateRouteResponse.ok) {
524
563
  console.warn(
525
- "Could not fetch workers.dev subdomain:",
526
- subdomainResponse.status,
527
- subdomainResponse.statusText,
564
+ `Failed to update route ${pattern}: ${updateRouteResponse.status} ${updateRouteResponse.statusText}`,
528
565
  );
529
566
  }
530
- } catch (error) {
531
- console.warn("Failed to enable worker URL:", error);
532
567
  }
533
- } else if (props.url === false && ctx.output?.url) {
534
- // Explicitly disable URL if it was previously enabled
535
- try {
536
- await api.post(
537
- `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
538
- JSON.stringify({ enabled: false }),
539
- {
540
- headers: { "Content-Type": "application/json" },
541
- },
568
+ } else {
569
+ // Create new route
570
+ const createRouteResponse = await api.post(
571
+ `/zones/${api.zoneId}/workers/routes`,
572
+ {
573
+ pattern,
574
+ script: workerName,
575
+ },
576
+ );
577
+
578
+ if (!createRouteResponse.ok) {
579
+ throw new Error(
580
+ `Failed to create route ${pattern}: ${createRouteResponse.status} ${createRouteResponse.statusText}`,
542
581
  );
543
- } catch (error) {
544
- console.warn("Failed to disable worker URL:", error);
545
582
  }
546
583
  }
584
+ }
585
+ }
586
+ }
547
587
 
548
- // Get current timestamp
549
- const now = Date.now();
550
-
551
- // Construct the output
552
- const output: WorkerOutput = {
553
- id: workerName,
554
- name: workerName,
555
- script: scriptContent,
556
- format: props.format || "esm", // Include format in the output
557
- routes: props.routes || [],
558
- bindings: props.bindings || [],
559
- production: props.production !== false,
560
- createdAt: now,
561
- updatedAt: now,
562
- url: workerUrl,
588
+ async function configureURL(
589
+ ctx: Context<WorkerOutput>,
590
+ api: CloudflareApi,
591
+ workerName: string,
592
+ url: boolean,
593
+ ) {
594
+ let workerUrl;
595
+ if (url) {
596
+ // Enable the workers.dev subdomain for this worker
597
+ await api.post(
598
+ `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
599
+ { enabled: true, previews_enabled: true },
600
+ {
601
+ headers: { "Content-Type": "application/json" },
602
+ },
603
+ );
604
+
605
+ // Get the account's workers.dev subdomain
606
+ const subdomainResponse = await api.get(
607
+ `/accounts/${api.accountId}/workers/subdomain`,
608
+ );
609
+
610
+ if (!subdomainResponse.ok) {
611
+ throw new Error(
612
+ `Could not fetch workers.dev subdomain: ${subdomainResponse.status} ${subdomainResponse.statusText}`,
613
+ );
614
+ }
615
+ const subdomainData: {
616
+ result: {
617
+ subdomain: string;
563
618
  };
619
+ } = await subdomainResponse.json();
620
+ const subdomain = subdomainData.result?.subdomain;
564
621
 
565
- return output;
622
+ if (subdomain) {
623
+ workerUrl = `https://${workerName}.${subdomain}.workers.dev`;
624
+
625
+ // Add a delay when the subdomain is first created.
626
+ // This is to prevent an issue where a negative cache-hit
627
+ // causes the subdomain to be unavailable for 30 seconds.
628
+ if (ctx.event === "create" || !ctx.output?.url) {
629
+ await new Promise((resolve) => setTimeout(resolve, 3000));
630
+ }
566
631
  }
567
- },
568
- ) {}
632
+ } else if (url === false && ctx.output?.url) {
633
+ // Explicitly disable URL if it was previously enabled
634
+ const response = await api.post(
635
+ `/accounts/${api.accountId}/workers/scripts/${workerName}/subdomain`,
636
+ JSON.stringify({ enabled: false }),
637
+ {
638
+ headers: { "Content-Type": "application/json" },
639
+ },
640
+ );
641
+ if (!response.ok) {
642
+ throw new Error(
643
+ `Failed to disable worker URL: ${response.status} ${response.statusText}`,
644
+ );
645
+ }
646
+ }
647
+ return workerUrl;
648
+ }
649
+
650
+ async function getWorkerScriptMetadata(
651
+ api: CloudflareApi,
652
+ workerName: string,
653
+ ): Promise<WorkerScriptMetadata | undefined> {
654
+ const response = await api.get(
655
+ `/accounts/${api.accountId}/workers/services/${workerName}`,
656
+ );
657
+ if (response.status === 404) {
658
+ return undefined;
659
+ }
660
+ if (!response.ok) {
661
+ throw new Error(
662
+ `Error getting worker script metadata: ${response.status} ${response.statusText}`,
663
+ );
664
+ }
665
+ return ((await response.json()) as any).result as WorkerScriptMetadata;
666
+ }
667
+
668
+ async function getWorkerBindings(
669
+ api: CloudflareApi,
670
+ workerName: string,
671
+ environment = "production",
672
+ ) {
673
+ const response = await api.get(
674
+ `/accounts/${api.accountId}/workers/services/${workerName}/environments/${environment}/bindings`,
675
+ {
676
+ headers: {
677
+ Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
678
+ "Content-Type": "application/json",
679
+ },
680
+ },
681
+ );
682
+
683
+ if (response.status === 404) {
684
+ return undefined;
685
+ }
686
+
687
+ if (!response.ok) {
688
+ throw new Error(
689
+ `Failed to fetch bindings: ${response.status} ${response.statusText}`,
690
+ );
691
+ }
692
+
693
+ const data: any = await response.json();
694
+
695
+ return data.result;
696
+ }