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.
- package/README.md +8 -3
- package/lib/ai/document.d.ts +12 -1
- package/lib/ai/document.js +10 -1
- package/lib/ai/index.d.ts +0 -2
- package/lib/ai/index.js +0 -2
- package/lib/alchemy.d.ts +5 -0
- package/lib/alchemy.js +44 -1
- package/lib/apply.js +17 -10
- package/lib/aws/account-id.d.ts +4 -1
- package/lib/aws/account-id.js +1 -1
- package/lib/aws/credentials.d.ts +5 -0
- package/lib/aws/credentials.js +0 -0
- package/lib/aws/oidc/oidc-provider.js +2 -2
- package/lib/aws/role.js +35 -23
- package/lib/cloudflare/account-api-token.d.ts +168 -0
- package/lib/cloudflare/account-api-token.js +179 -0
- package/lib/cloudflare/account-id.d.ts +1 -0
- package/lib/cloudflare/account-id.js +0 -0
- package/lib/cloudflare/api.d.ts +38 -1
- package/lib/cloudflare/api.js +55 -4
- package/lib/cloudflare/assets.d.ts +74 -0
- package/lib/cloudflare/assets.js +73 -0
- package/lib/cloudflare/bindings.d.ts +6 -1
- package/lib/cloudflare/bindings.js +6 -0
- package/lib/cloudflare/bucket.d.ts +118 -3
- package/lib/cloudflare/bucket.js +269 -89
- package/lib/cloudflare/custom-domain.d.ts +75 -0
- package/lib/cloudflare/custom-domain.js +154 -0
- package/lib/cloudflare/{dns.js → dns-records.js} +14 -1
- package/lib/cloudflare/index.d.ts +6 -1
- package/lib/cloudflare/index.js +6 -1
- package/lib/cloudflare/permission-groups.d.ts +78 -0
- package/lib/cloudflare/permission-groups.js +48 -0
- package/lib/cloudflare/r2-rest-state-store.d.ts +2 -1
- package/lib/cloudflare/r2-rest-state-store.js +55 -24
- package/lib/cloudflare/static-site.d.ts +17 -18
- package/lib/cloudflare/static-site.js +19 -10
- package/lib/cloudflare/worker.d.ts +14 -0
- package/lib/cloudflare/worker.js +150 -3
- package/lib/{util/encrypt.d.ts → encrypt.d.ts} +1 -1
- package/lib/{util/encrypt.js → encrypt.js} +1 -1
- package/lib/fs/file-system-state-store.js +1 -1
- package/lib/fs/file.d.ts +18 -0
- package/lib/fs/file.js +6 -0
- package/lib/internal/{providers.d.ts → docs/providers.d.ts} +10 -4
- package/lib/internal/docs/providers.js +196 -0
- package/lib/secret.d.ts +3 -0
- package/lib/secret.js +13 -0
- package/lib/{util/serde.d.ts → serde.d.ts} +1 -1
- package/lib/{util/serde.js → serde.js} +12 -4
- package/lib/test/bun.js +3 -2
- package/lib/util/sha256.d.ts +1 -0
- package/lib/util/sha256.js +4 -0
- package/lib/web/vitepress/config.d.ts +2 -1
- package/lib/web/vitepress/config.js +1 -0
- package/lib/web/vitepress/index.d.ts +1 -0
- package/lib/web/vitepress/index.js +1 -0
- package/lib/web/vitepress/process-front-matter-files.d.ts +18 -0
- package/lib/web/vitepress/process-front-matter-files.js +68 -0
- package/lib/web/vitepress/vitepress.js +3 -2
- package/package.json +8 -4
- package/src/ai/document.ts +26 -2
- package/src/ai/index.ts +0 -2
- package/src/alchemy.ts +56 -2
- package/src/apply.ts +21 -20
- package/src/aws/account-id.ts +6 -2
- package/src/aws/credentials.ts +6 -0
- package/src/aws/oidc/oidc-provider.ts +17 -17
- package/src/aws/role.ts +68 -54
- package/src/cloudflare/account-api-token.ts +365 -0
- package/src/cloudflare/account-id.ts +0 -0
- package/src/cloudflare/api.ts +88 -17
- package/src/cloudflare/assets.ts +155 -0
- package/src/cloudflare/bindings.ts +11 -2
- package/src/cloudflare/bucket.ts +493 -133
- package/src/cloudflare/custom-domain.ts +318 -0
- package/src/cloudflare/{dns.ts → dns-records.ts} +36 -22
- package/src/cloudflare/index.ts +6 -1
- package/src/cloudflare/permission-groups.ts +137 -0
- package/src/cloudflare/r2-rest-state-store.ts +87 -42
- package/src/cloudflare/static-site.ts +20 -32
- package/src/cloudflare/worker.ts +253 -5
- package/src/dns/import-dns.ts +4 -4
- package/src/{util/encrypt.ts → encrypt.ts} +7 -10
- package/src/fs/file-system-state-store.ts +5 -5
- package/src/fs/file.ts +29 -0
- package/src/internal/docs/providers.ts +281 -0
- package/src/secret.ts +16 -0
- package/src/{util/serde.ts → serde.ts} +16 -10
- package/src/test/bun.ts +8 -7
- package/src/util/sha256.ts +5 -0
- package/src/web/vitepress/config.ts +3 -1
- package/src/web/vitepress/index.ts +1 -0
- package/src/web/vitepress/process-front-matter-files.ts +98 -0
- package/src/web/vitepress/vitepress.ts +3 -2
- package/lib/ai/approve.d.ts +0 -99
- package/lib/ai/approve.js +0 -76
- package/lib/ai/review.d.ts +0 -122
- package/lib/ai/review.js +0 -101
- package/lib/internal/getting-started.d.ts +0 -21
- package/lib/internal/getting-started.js +0 -87
- package/lib/internal/index.d.ts +0 -3
- package/lib/internal/index.js +0 -3
- package/lib/internal/providers.js +0 -172
- package/lib/internal/tutorial.d.ts +0 -104
- package/lib/internal/tutorial.js +0 -251
- package/src/ai/approve.ts +0 -163
- package/src/ai/review.ts +0 -213
- package/src/internal/getting-started.ts +0 -115
- package/src/internal/index.ts +0 -3
- package/src/internal/providers.ts +0 -241
- package/src/internal/tutorial.ts +0 -392
- /package/lib/cloudflare/{dns.d.ts → dns-records.d.ts} +0 -0
package/lib/cloudflare/bucket.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
*
|
|
255
|
+
* Set CORS configuration for a bucket using aws4fetch
|
|
133
256
|
*/
|
|
134
|
-
async function
|
|
257
|
+
export async function setCorsConfiguration(r2, bucketName, allowedOrigins = ["*"], allowedMethods = ["GET", "HEAD", "PUT", "POST", "DELETE"], allowedHeaders = ["*"], maxAgeSeconds = 3600, jurisdiction) {
|
|
135
258
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 "./
|
|
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";
|