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,5 +1,6 @@
1
+ import { AwsClient } from "aws4fetch";
1
2
  import { Resource } from "../resource";
2
- import { createCloudflareApi } from "./api";
3
+ import { CloudflareAccountId, CloudflareApi, CloudflareApiError, createCloudflareApi, handleApiError, } from "./api";
3
4
  /**
4
5
  * Creates and manages Cloudflare R2 Buckets for object storage.
5
6
  *
@@ -37,113 +38,292 @@ import { createCloudflareApi } from "./api";
37
38
  * @see https://developers.cloudflare.com/r2/buckets/
38
39
  */
39
40
  export const R2Bucket = Resource("cloudflare::R2Bucket", async function (id, props) {
40
- // Create Cloudflare API client with automatic account discovery
41
- const api = await createCloudflareApi();
42
- // Resource ID defaults to bucket name if provided
41
+ const [api, r2Client] = await Promise.all([
42
+ createCloudflareApi(props),
43
+ createR2Client(props),
44
+ ]);
43
45
  const bucketName = props.name || this.id;
44
46
  if (this.phase === "delete") {
45
- if (this.output?.name) {
47
+ console.log("Deleting R2 bucket:", bucketName);
48
+ if (props.delete !== false) {
49
+ // Empty the bucket first by deleting all objects
50
+ await emptyBucket(r2Client, bucketName, props.jurisdiction);
46
51
  // Delete R2 bucket
47
- const headers = {};
48
- if (props.jurisdiction && props.jurisdiction !== "default") {
49
- headers["cf-r2-jurisdiction"] = props.jurisdiction;
50
- }
51
- const deleteResponse = await api.delete(`/accounts/${api.accountId}/r2/buckets/${this.output.name}`, { headers });
52
- if (!deleteResponse.ok && deleteResponse.status !== 404) {
53
- const errorData = await deleteResponse.json().catch(() => ({
54
- errors: [{ message: deleteResponse.statusText }],
55
- }));
56
- throw new Error(`Error deleting R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`);
57
- }
52
+ console.log("Deleting R2 bucket:", bucketName);
53
+ await deleteBucket(api, bucketName, props);
58
54
  }
59
55
  // Return void (a deleted bucket has no content)
60
56
  return this.destroy();
61
57
  }
62
58
  else {
63
- try {
64
- let bucketOutput;
65
- if (this.phase === "update" && this.output?.name) {
66
- // Get bucket details to verify it exists
67
- const headers = {};
68
- if (props.jurisdiction && props.jurisdiction !== "default") {
69
- headers["cf-r2-jurisdiction"] = props.jurisdiction;
70
- }
71
- const getResponse = await api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, { headers });
72
- if (!getResponse.ok) {
73
- const errorData = await getResponse.json().catch(() => ({
74
- errors: [{ message: getResponse.statusText }],
75
- }));
76
- throw new Error(`Error getting R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || getResponse.statusText}`);
77
- }
78
- const result = (await getResponse.json());
79
- bucketOutput = this({
80
- name: result.result.name,
81
- location: result.result.location || "default",
82
- creationDate: Math.floor(new Date(result.result.creation_date).getTime() / 1000),
83
- jurisdiction: props.jurisdiction || "default",
84
- type: "r2_bucket",
85
- });
86
- // Update public access setting if it has changed
87
- if (props.allowPublicAccess !== undefined) {
88
- await updatePublicAccess(api, bucketName, props.allowPublicAccess);
89
- }
90
- }
91
- else {
92
- // Create new R2 bucket
93
- const createPayload = {
94
- name: bucketName,
95
- };
96
- if (props.locationHint) {
97
- createPayload.location_hint = props.locationHint;
98
- }
99
- const headers = {};
100
- if (props.jurisdiction && props.jurisdiction !== "default") {
101
- headers["cf-r2-jurisdiction"] = props.jurisdiction;
102
- }
103
- const createResponse = await api.post(`/accounts/${api.accountId}/r2/buckets`, createPayload, { headers });
104
- if (!createResponse.ok) {
105
- const errorData = await createResponse.json().catch(() => ({
106
- errors: [{ message: createResponse.statusText }],
107
- }));
108
- throw new Error(`Error creating R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || createResponse.statusText}`);
109
- }
110
- const result = (await createResponse.json());
111
- bucketOutput = this({
112
- name: result.result.name,
113
- location: result.result.location || "default",
114
- creationDate: Math.floor(new Date(result.result.creation_date).getTime() / 1000),
115
- jurisdiction: props.jurisdiction || "default",
116
- type: "r2_bucket",
117
- });
118
- // Set public access if requested
119
- if (props.allowPublicAccess) {
120
- await updatePublicAccess(api, bucketName, true);
59
+ if (this.phase === "create") {
60
+ console.log("Creating R2 bucket:", bucketName);
61
+ await createBucket(api, bucketName, props);
62
+ }
63
+ await updatePublicAccess(api, bucketName, props.allowPublicAccess === true, props.jurisdiction);
64
+ return this({
65
+ name: bucketName,
66
+ location: props.locationHint || "default",
67
+ creationDate: new Date(),
68
+ jurisdiction: props.jurisdiction || "default",
69
+ type: "r2_bucket",
70
+ });
71
+ }
72
+ });
73
+ /**
74
+ * Creates an aws4fetch client configured for Cloudflare R2
75
+ *
76
+ * @see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/
77
+ */
78
+ export async function createR2Client(config = {}) {
79
+ const accountId = await CloudflareAccountId(config);
80
+ const accessKeyId = config.accessKeyId || process.env.R2_ACCESS_KEY_ID;
81
+ const secretAccessKey = config.secretAccessKey || process.env.R2_SECRET_ACCESS_KEY;
82
+ if (!accountId) {
83
+ throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required");
84
+ }
85
+ if (!accessKeyId || !secretAccessKey) {
86
+ throw new Error("R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY environment variables are required");
87
+ }
88
+ // Create aws4fetch client with Cloudflare R2 endpoint
89
+ const client = new AwsClient({
90
+ accessKeyId,
91
+ secretAccessKey,
92
+ service: "s3",
93
+ region: "auto",
94
+ });
95
+ client.accountId = accountId;
96
+ return client;
97
+ }
98
+ /**
99
+ * Adds jurisdiction header to the headers object if specified in props
100
+ *
101
+ * @param headers Headers object to modify
102
+ * @param props Props or jurisdiction string
103
+ * @returns Modified headers object
104
+ */
105
+ export function withJurisdiction(headers = {}, props) {
106
+ // Clone the headers object to avoid modifying the original
107
+ const result = { ...headers };
108
+ let jurisdiction;
109
+ if (typeof props === "string") {
110
+ jurisdiction = props;
111
+ }
112
+ else if (props && "jurisdiction" in props) {
113
+ jurisdiction = props.jurisdiction;
114
+ }
115
+ if (jurisdiction && jurisdiction !== "default") {
116
+ result["cf-r2-jurisdiction"] = jurisdiction;
117
+ }
118
+ return result;
119
+ }
120
+ /**
121
+ * Create a new bucket
122
+ */
123
+ export async function createBucket(api, bucketName, props) {
124
+ // Create new R2 bucket
125
+ const createPayload = {
126
+ name: bucketName,
127
+ };
128
+ if (props.locationHint) {
129
+ createPayload.location_hint = props.locationHint;
130
+ }
131
+ const headers = withJurisdiction({}, props);
132
+ const createResponse = await api.post(`/accounts/${api.accountId}/r2/buckets`, createPayload, { headers });
133
+ if (!createResponse.ok) {
134
+ const errorData = await createResponse.json().catch(() => ({
135
+ errors: [{ message: createResponse.statusText }],
136
+ }));
137
+ throw new CloudflareApiError(`Error creating R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || createResponse.statusText}`, createResponse);
138
+ }
139
+ return (await createResponse.json());
140
+ }
141
+ /**
142
+ * Delete a bucket
143
+ */
144
+ export async function deleteBucket(api, bucketName, props) {
145
+ // Delete R2 bucket
146
+ const headers = withJurisdiction({}, props);
147
+ const deleteResponse = await api.delete(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, { headers });
148
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
149
+ const errorData = await deleteResponse.json().catch(() => ({
150
+ errors: [{ message: deleteResponse.statusText }],
151
+ }));
152
+ throw new CloudflareApiError(`Error deleting R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`, deleteResponse);
153
+ }
154
+ }
155
+ /**
156
+ * List objects in an R2 bucket
157
+ *
158
+ * @param r2 R2Client instance
159
+ * @param bucketName Name of the bucket
160
+ * @param continuationToken Optional token for pagination
161
+ * @param jurisdiction Optional jurisdiction for the bucket
162
+ * @returns Object containing the list of objects and the next continuation token
163
+ */
164
+ export async function listObjects(r2, bucketName, continuationToken, jurisdiction) {
165
+ // List objects in the bucket
166
+ const url = new URL(`https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}`);
167
+ if (continuationToken) {
168
+ url.searchParams.set("continuation-token", continuationToken);
169
+ }
170
+ url.searchParams.set("list-type", "2");
171
+ const headers = withJurisdiction({}, jurisdiction);
172
+ const listResponse = await r2.fetch(url.toString(), { headers });
173
+ if (!listResponse.ok) {
174
+ throw new CloudflareApiError(`Failed to list objects: ${listResponse.statusText}`, listResponse);
175
+ }
176
+ const responseText = await listResponse.text();
177
+ // Extract objects from XML response using regex
178
+ const keyRegex = /<Key>([^<]+)<\/Key>/g;
179
+ const objects = [];
180
+ let match;
181
+ while ((match = keyRegex.exec(responseText)) !== null) {
182
+ objects.push({ Key: match[1] });
183
+ }
184
+ // Get continuation token if present using regex
185
+ const tokenMatch = /<NextContinuationToken>([^<]+)<\/NextContinuationToken>/.exec(responseText);
186
+ const nextContinuationToken = tokenMatch ? tokenMatch[1] : undefined;
187
+ return { objects, continuationToken: nextContinuationToken };
188
+ }
189
+ /**
190
+ * Helper function to empty a bucket by deleting all objects
191
+ */
192
+ export async function emptyBucket(r2, bucketName, jurisdiction) {
193
+ let continuationToken;
194
+ let totalDeleted = 0;
195
+ try {
196
+ do {
197
+ // List objects in the bucket
198
+ const { objects, continuationToken: nextToken } = await listObjects(r2, bucketName, continuationToken, jurisdiction);
199
+ continuationToken = nextToken;
200
+ console.log(`Found ${objects.length} objects in bucket ${bucketName}`);
201
+ // Delete objects in batches
202
+ if (objects.length > 0) {
203
+ // Process delete in batches of 1000 (S3 limit)
204
+ for (let i = 0; i < objects.length; i += 1000) {
205
+ const batch = objects.slice(i, i + 1000);
206
+ // Create DeleteObjects request XML
207
+ const deleteXml = `
208
+ <Delete>
209
+ ${batch.map((obj) => `<Object><Key>${obj.Key}</Key></Object>`).join("")}
210
+ </Delete>
211
+ `;
212
+ const deleteUrl = new URL(`https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}?delete`);
213
+ console.log(`Deleting ${batch.length} objects from bucket ${bucketName}`);
214
+ const headers = withJurisdiction({ "Content-Type": "application/xml" }, jurisdiction);
215
+ const deleteResponse = await r2.fetch(deleteUrl.toString(), {
216
+ method: "POST",
217
+ body: deleteXml,
218
+ headers,
219
+ });
220
+ if (!deleteResponse.ok) {
221
+ throw new CloudflareApiError(`Failed to delete objects: ${deleteResponse.statusText}`, deleteResponse);
222
+ }
223
+ totalDeleted += batch.length;
121
224
  }
122
225
  }
123
- return bucketOutput;
124
- }
125
- catch (error) {
126
- console.error("Error creating/updating R2 bucket:", error);
127
- throw error;
226
+ } while (continuationToken);
227
+ console.log(`Successfully emptied bucket ${bucketName}, deleted ${totalDeleted} objects total`);
228
+ }
229
+ catch (error) {
230
+ if (error instanceof CloudflareApiError && error.status === 404) {
231
+ // the bucket was not found
232
+ return;
128
233
  }
234
+ console.error(`Failed to empty bucket ${bucketName}:`, error);
235
+ throw error;
129
236
  }
130
- });
237
+ }
238
+ /**
239
+ * Update public access setting for a bucket
240
+ *
241
+ * This operation is not available through the S3 API for R2,
242
+ * so we still use the Cloudflare API directly.
243
+ */
244
+ export async function updatePublicAccess(api, bucketName, allowPublicAccess, jurisdiction) {
245
+ console.log(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`);
246
+ const headers = withJurisdiction({}, jurisdiction);
247
+ const response = await api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`, {
248
+ enabled: allowPublicAccess,
249
+ }, { headers });
250
+ if (!response.ok) {
251
+ await handleApiError(response, "updating public access for", "R2 bucket", bucketName);
252
+ }
253
+ }
131
254
  /**
132
- * Helper function to update the public access setting for a bucket
255
+ * Set CORS configuration for a bucket using aws4fetch
133
256
  */
134
- async function updatePublicAccess(api, bucketName, allowPublicAccess) {
257
+ export async function setCorsConfiguration(r2, bucketName, allowedOrigins = ["*"], allowedMethods = ["GET", "HEAD", "PUT", "POST", "DELETE"], allowedHeaders = ["*"], maxAgeSeconds = 3600, jurisdiction) {
135
258
  try {
136
- const response = await api.put(`/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`, {
137
- enabled: allowPublicAccess,
259
+ // Construct CORS XML configuration
260
+ const corsXml = `
261
+ <CORSConfiguration>
262
+ <CORSRule>
263
+ ${allowedOrigins.map((origin) => `<AllowedOrigin>${origin}</AllowedOrigin>`).join("")}
264
+ ${allowedMethods.map((method) => `<AllowedMethod>${method}</AllowedMethod>`).join("")}
265
+ ${allowedHeaders.map((header) => `<AllowedHeader>${header}</AllowedHeader>`).join("")}
266
+ <ExposeHeader>ETag</ExposeHeader>
267
+ <MaxAgeSeconds>${maxAgeSeconds}</MaxAgeSeconds>
268
+ </CORSRule>
269
+ </CORSConfiguration>
270
+ `;
271
+ const url = new URL(`https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}?cors`);
272
+ const headers = withJurisdiction({ "Content-Type": "application/xml" }, jurisdiction);
273
+ const response = await r2.fetch(url.toString(), {
274
+ method: "PUT",
275
+ body: corsXml,
276
+ headers,
138
277
  });
139
278
  if (!response.ok) {
140
- const errorData = await response.json().catch(() => ({
141
- errors: [{ message: response.statusText }],
142
- }));
143
- throw new Error(`Error updating R2 bucket public access: ${errorData.errors?.[0]?.message || response.statusText}`);
279
+ throw new CloudflareApiError(`Failed to set CORS configuration: ${response.statusText}`, response);
144
280
  }
281
+ console.log(`Successfully set CORS configuration for bucket ${bucketName}`);
145
282
  }
146
283
  catch (error) {
147
- console.warn("Could not set public access:", error);
284
+ console.error(`Failed to set CORS configuration for bucket ${bucketName}:`, error);
285
+ throw error;
286
+ }
287
+ }
288
+ /**
289
+ * List all R2 buckets in an account
290
+ *
291
+ * @param api CloudflareApi instance
292
+ * @param options Optional listing options
293
+ * @returns Array of bucket information
294
+ */
295
+ export async function listBuckets(api, options = {}) {
296
+ // Build query parameters
297
+ const params = new URLSearchParams();
298
+ if (options.nameContains) {
299
+ params.append("name_contains", options.nameContains);
300
+ }
301
+ if (options.perPage) {
302
+ params.append("per_page", options.perPage.toString());
303
+ }
304
+ if (options.cursor) {
305
+ params.append("cursor", options.cursor);
306
+ }
307
+ if (options.direction) {
308
+ params.append("direction", options.direction);
309
+ }
310
+ // Build URL with query parameters
311
+ const path = `/accounts/${api.accountId}/r2/buckets${params.toString() ? "?" + params.toString() : ""}`;
312
+ // Set jurisdiction header if provided
313
+ const headers = withJurisdiction({}, options.jurisdiction);
314
+ // Make the API request
315
+ const response = await api.get(path, { headers });
316
+ if (!response.ok) {
317
+ throw new CloudflareApiError(`Failed to list buckets: ${response.statusText}`, response);
318
+ }
319
+ const data = (await response.json());
320
+ if (!data.success) {
321
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
322
+ throw new Error(`Failed to list buckets: ${errorMessage}`);
148
323
  }
324
+ // Transform API response to R2BucketInfo objects
325
+ return (data.result?.buckets || []).map((bucket) => ({
326
+ Name: bucket.name,
327
+ CreationDate: new Date(bucket.creation_date),
328
+ }));
149
329
  }
@@ -0,0 +1,75 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ /**
4
+ * Properties for creating or updating a CustomDomain
5
+ */
6
+ export interface CustomDomainProps {
7
+ /**
8
+ * The domain name to bind to the worker
9
+ */
10
+ name: string;
11
+ /**
12
+ * Cloudflare Zone ID for the domain
13
+ */
14
+ zoneId: string;
15
+ /**
16
+ * Name of the worker to bind to the domain
17
+ */
18
+ workerName: string;
19
+ /**
20
+ * Worker environment (defaults to production)
21
+ * @default "production"
22
+ */
23
+ environment?: string;
24
+ }
25
+ /**
26
+ * Output returned after CustomDomain creation/update
27
+ */
28
+ export interface CustomDomain extends Resource<"cloudflare::CustomDomain">, CustomDomainProps {
29
+ /**
30
+ * The unique identifier for the Cloudflare domain binding.
31
+ */
32
+ id: string;
33
+ /**
34
+ * Time at which the domain binding was created (approximated if not returned by API)
35
+ */
36
+ createdAt: number;
37
+ /**
38
+ * Time at which the domain binding was last updated
39
+ */
40
+ updatedAt: number;
41
+ }
42
+ /**
43
+ * Configure custom domain for a Cloudflare Worker using the Cloudflare Custom Domains API
44
+ * This attaches a worker (either a standard Worker or the one backing a StaticSite)
45
+ * to a specific hostname within a zone.
46
+ *
47
+ * @example
48
+ * // Bind a domain to a standard Cloudflare Worker
49
+ * const apiWorker = await Worker("api", {
50
+ * name: "my-api-worker",
51
+ * entrypoint: "./src/api-worker.ts"
52
+ * });
53
+ *
54
+ * const apiDomain = await CustomDomain("api-domain-binding", {
55
+ * name: "api.example.com",
56
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
57
+ * workerName: apiWorker.name // Use the name from the Worker resource
58
+ * });
59
+ *
60
+ * @example
61
+ * // Bind a domain to a Cloudflare Static Site
62
+ * const mySite = await StaticSite("landing-page", {
63
+ * name: "my-landing-page-site", // This becomes the underlying worker name
64
+ * dir: "./dist/landing-page"
65
+ * });
66
+ *
67
+ * const siteDomain = await CustomDomain("site-domain-binding", {
68
+ * name: "www.example.com",
69
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
70
+ * workerName: mySite.name // Use the name from the StaticSite resource
71
+ * });
72
+ *
73
+ * @see https://developers.cloudflare.com/api/resources/workers/subresources/domains/
74
+ */
75
+ export declare const CustomDomain: (((this: any, id: string, props?: {}) => never) & (new (_: never) => never)) | ((this: Context<CustomDomain>, logicalId: string, props: CustomDomainProps) => Promise<CustomDomain>);
@@ -0,0 +1,154 @@
1
+ import { Resource } from "../resource";
2
+ import { CloudflareApi, createCloudflareApi, handleApiError } from "./api";
3
+ /**
4
+ * Configure custom domain for a Cloudflare Worker using the Cloudflare Custom Domains API
5
+ * This attaches a worker (either a standard Worker or the one backing a StaticSite)
6
+ * to a specific hostname within a zone.
7
+ *
8
+ * @example
9
+ * // Bind a domain to a standard Cloudflare Worker
10
+ * const apiWorker = await Worker("api", {
11
+ * name: "my-api-worker",
12
+ * entrypoint: "./src/api-worker.ts"
13
+ * });
14
+ *
15
+ * const apiDomain = await CustomDomain("api-domain-binding", {
16
+ * name: "api.example.com",
17
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
18
+ * workerName: apiWorker.name // Use the name from the Worker resource
19
+ * });
20
+ *
21
+ * @example
22
+ * // Bind a domain to a Cloudflare Static Site
23
+ * const mySite = await StaticSite("landing-page", {
24
+ * name: "my-landing-page-site", // This becomes the underlying worker name
25
+ * dir: "./dist/landing-page"
26
+ * });
27
+ *
28
+ * const siteDomain = await CustomDomain("site-domain-binding", {
29
+ * name: "www.example.com",
30
+ * zoneId: "YOUR_ZONE_ID", // Replace with actual Zone ID
31
+ * workerName: mySite.name // Use the name from the StaticSite resource
32
+ * });
33
+ *
34
+ * @see https://developers.cloudflare.com/api/resources/workers/subresources/domains/
35
+ */
36
+ export const CustomDomain = Resource("cloudflare::CustomDomain", async function (logicalId, // Changed param name from id to logicalId for clarity
37
+ props) {
38
+ // Create Cloudflare API client with automatic account discovery
39
+ const api = await createCloudflareApi();
40
+ // Validate required properties
41
+ if (!props.name) {
42
+ throw new Error("Domain name (props.name) is required");
43
+ }
44
+ if (!props.zoneId) {
45
+ throw new Error("Zone ID (props.zoneId) is required");
46
+ }
47
+ if (!props.workerName) {
48
+ throw new Error("Worker name (props.workerName) is required");
49
+ }
50
+ if (this.phase === "delete") {
51
+ await deleteCustomDomain(this, api, logicalId, props);
52
+ return this.destroy();
53
+ }
54
+ else {
55
+ // Create or Update phase
56
+ return await ensureCustomDomain(this, api, logicalId, props);
57
+ }
58
+ });
59
+ // Helper function to delete the custom domain binding
60
+ async function deleteCustomDomain(context, api, logicalId, props) {
61
+ const domainHostname = props.name;
62
+ const domainIdToDelete = context.output?.id;
63
+ if (!domainIdToDelete) {
64
+ console.warn(`Cannot delete CustomDomain ${logicalId} (${domainHostname}): Missing domain ID in state. Assuming already deleted.`);
65
+ return; // Exit early if no ID
66
+ }
67
+ console.log(`Deleting CustomDomain binding ${domainIdToDelete} for ${domainHostname}`);
68
+ const response = await api.delete(`/accounts/${api.accountId}/workers/domains/${domainIdToDelete}`);
69
+ console.log(`Delete result for ${domainIdToDelete} (${domainHostname}):`, response.status, response.statusText);
70
+ // 404 is acceptable during deletion for idempotency
71
+ if (!response.ok && response.status !== 404) {
72
+ await handleApiError(response, "deleting", "custom domain binding", domainIdToDelete);
73
+ // Throw after handling to ensure failure is reported
74
+ throw new Error(`Failed to delete custom domain binding ${domainIdToDelete}: ${response.statusText}`);
75
+ }
76
+ }
77
+ // Helper function to create or update the custom domain binding
78
+ async function ensureCustomDomain(context, api, logicalId, props) {
79
+ const environment = props.environment || "production";
80
+ const domainHostname = props.name;
81
+ // Check if domain binding already exists for this account
82
+ console.log(`Checking existing domain bindings for account ${api.accountId}`);
83
+ const listResponse = await api.get(`/accounts/${api.accountId}/workers/domains`);
84
+ if (!listResponse.ok) {
85
+ // Fix: Added the 4th argument (resource identifier/context)
86
+ await handleApiError(listResponse, "listing", "worker domains", `Account ${api.accountId}`);
87
+ // If listing fails, we cannot proceed reliably
88
+ throw new Error(`Failed to list worker domains for account ${api.accountId}: ${listResponse.statusText}`);
89
+ }
90
+ const listData = (await listResponse.json());
91
+ if (!listData.success || !listData.result) {
92
+ throw new Error(`Failed to parse list worker domains response: ${JSON.stringify(listData)}`);
93
+ }
94
+ // Find the specific binding by hostname AND zoneId
95
+ const existingBinding = listData.result.find((b) => b.hostname === domainHostname && b.zone_id === props.zoneId);
96
+ let currentDomainId = existingBinding?.id;
97
+ const bindingExists = !!existingBinding;
98
+ console.log(`Domain binding status for ${domainHostname} (Zone: ${props.zoneId}):`, bindingExists
99
+ ? `Found (ID: ${currentDomainId}, Worker: ${existingBinding.service}, Env: ${existingBinding.environment})`
100
+ : "Not found");
101
+ // Determine if we need to update (binding exists but has different service or environment)
102
+ const needsUpdate = bindingExists &&
103
+ (existingBinding.service !== props.workerName ||
104
+ existingBinding.environment !== environment);
105
+ let operationPerformed = "none";
106
+ let resultantBinding = existingBinding;
107
+ // Create or Update the binding using PUT
108
+ // Cloudflare's PUT /accounts/{account_id}/workers/domains acts as an upsert
109
+ if (!bindingExists || needsUpdate) {
110
+ operationPerformed = bindingExists ? "update" : "create";
111
+ console.log(`${operationPerformed === "update" ? "Updating" : "Creating"} domain binding: ${domainHostname} (Zone: ${props.zoneId}) → ${props.workerName}:${environment}`);
112
+ const putPayload = {
113
+ zone_id: props.zoneId,
114
+ hostname: domainHostname,
115
+ service: props.workerName,
116
+ environment: environment,
117
+ };
118
+ const putResponse = await api.put(`/accounts/${api.accountId}/workers/domains`, putPayload);
119
+ if (!putResponse.ok) {
120
+ await handleApiError(putResponse, operationPerformed === "update" ? "updating" : "creating", "custom domain binding", domainHostname);
121
+ // Throw after handling to prevent inconsistent state
122
+ throw new Error(`Failed to ${operationPerformed} custom domain binding: ${putResponse.statusText}`);
123
+ }
124
+ const putResult = (await putResponse.json());
125
+ if (!putResult.success || !putResult.result) {
126
+ throw new Error(`Failed to parse ${operationPerformed} domain binding response: ${JSON.stringify(putResult)}`);
127
+ }
128
+ resultantBinding = putResult.result;
129
+ currentDomainId = resultantBinding.id; // Update ID from the PUT response
130
+ console.log(`Successfully ${operationPerformed}d binding, new ID: ${currentDomainId}`);
131
+ }
132
+ else {
133
+ console.log(`Domain binding already exists and is up to date: ${domainHostname} (ID: ${currentDomainId}) → ${props.workerName}:${environment}`);
134
+ }
135
+ // Ensure we have the final binding details
136
+ if (!resultantBinding || !currentDomainId) {
137
+ // This case should ideally not happen if API calls succeed
138
+ console.error("Error: Could not determine final domain binding state.", {
139
+ existingBinding,
140
+ resultantBinding,
141
+ currentDomainId,
142
+ });
143
+ throw new Error(`Failed to get final state for custom domain ${domainHostname}`);
144
+ }
145
+ const now = Date.now();
146
+ // Construct the output state
147
+ return context({
148
+ ...props, // Include all input props
149
+ id: currentDomainId, // Use the definitive ID
150
+ environment: resultantBinding.environment, // Use actual environment from CF
151
+ createdAt: context.output?.createdAt || now, // Preserve create time or set new
152
+ updatedAt: operationPerformed !== "none" ? now : context.output?.updatedAt || now, // Update time only if changed
153
+ });
154
+ }
@@ -111,7 +111,20 @@ export const DnsRecords = Resource("cloudflare::DnsRecords", async function (id,
111
111
  }
112
112
  // Create new records
113
113
  const uniqueRecords = props.records.reduce((acc, record) => {
114
- const key = `${record.name}-${record.type}`;
114
+ // For record types that can have multiple entries with the same name (MX, TXT, NS, etc.),
115
+ // include content and/or priority in the key to avoid deduplication
116
+ let key = `${record.name}-${record.type}`;
117
+ // If it's a record type that can have multiple entries with the same name, make the key unique
118
+ if (["MX", "TXT", "NS", "SRV", "CAA"].includes(record.type)) {
119
+ // For MX, include priority in the key
120
+ if (record.type === "MX" || record.type === "SRV") {
121
+ key = `${key}-${record.priority}-${record.content}`;
122
+ }
123
+ else {
124
+ // For other multi-record types, content is the differentiator
125
+ key = `${key}-${record.content}`;
126
+ }
127
+ }
115
128
  acc[key] = record;
116
129
  return acc;
117
130
  }, {});
@@ -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";