alchemy 0.49.1 → 0.50.0

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 (39) hide show
  1. package/lib/cloudflare/api-response.d.ts +2 -2
  2. package/lib/cloudflare/api-response.d.ts.map +1 -1
  3. package/lib/cloudflare/api-response.js +2 -1
  4. package/lib/cloudflare/api-response.js.map +1 -1
  5. package/lib/cloudflare/bucket.d.ts +70 -105
  6. package/lib/cloudflare/bucket.d.ts.map +1 -1
  7. package/lib/cloudflare/bucket.js +127 -244
  8. package/lib/cloudflare/bucket.js.map +1 -1
  9. package/lib/cloudflare/nuxt.js +1 -1
  10. package/lib/cloudflare/nuxt.js.map +1 -1
  11. package/lib/cloudflare/orange.d.ts.map +1 -1
  12. package/lib/cloudflare/orange.js +4 -1
  13. package/lib/cloudflare/orange.js.map +1 -1
  14. package/lib/cloudflare/redwood.d.ts.map +1 -1
  15. package/lib/cloudflare/redwood.js +5 -1
  16. package/lib/cloudflare/redwood.js.map +1 -1
  17. package/lib/cloudflare/vite.d.ts.map +1 -1
  18. package/lib/cloudflare/vite.js +2 -10
  19. package/lib/cloudflare/vite.js.map +1 -1
  20. package/lib/cloudflare/website.d.ts.map +1 -1
  21. package/lib/cloudflare/website.js +1 -0
  22. package/lib/cloudflare/website.js.map +1 -1
  23. package/lib/cloudflare/worker.d.ts.map +1 -1
  24. package/lib/cloudflare/worker.js +1 -0
  25. package/lib/cloudflare/worker.js.map +1 -1
  26. package/lib/os/exec.d.ts +1 -1
  27. package/lib/os/exec.d.ts.map +1 -1
  28. package/lib/os/exec.js +1 -1
  29. package/lib/os/exec.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cloudflare/api-response.ts +7 -3
  32. package/src/cloudflare/bucket.ts +276 -468
  33. package/src/cloudflare/nuxt.ts +1 -1
  34. package/src/cloudflare/orange.ts +4 -1
  35. package/src/cloudflare/redwood.ts +6 -1
  36. package/src/cloudflare/vite.ts +2 -10
  37. package/src/cloudflare/website.ts +1 -0
  38. package/src/cloudflare/worker.ts +2 -1
  39. package/src/os/exec.ts +2 -2
@@ -1,18 +1,24 @@
1
- import { AwsClient } from "aws4fetch";
1
+ import { isDeepStrictEqual } from "node:util";
2
2
  import type { Context } from "../context.ts";
3
3
  import { Resource, ResourceKind } from "../resource.ts";
4
4
  import { bind } from "../runtime/bind.ts";
5
- import type { Secret } from "../secret.ts";
6
- import { logger } from "../util/logger.ts";
7
5
  import { withExponentialBackoff } from "../util/retry.ts";
8
- import { CloudflareApiError, handleApiError } from "./api-error.ts";
9
- import { type CloudflareApi, createCloudflareApi } from "./api.ts";
6
+ import { CloudflareApiError } from "./api-error.ts";
7
+ import {
8
+ extractCloudflareError,
9
+ extractCloudflareResult,
10
+ } from "./api-response.ts";
11
+ import {
12
+ createCloudflareApi,
13
+ type CloudflareApi,
14
+ type CloudflareApiOptions,
15
+ } from "./api.ts";
10
16
  import type { Bound } from "./bound.ts";
11
17
 
12
18
  /**
13
19
  * Properties for creating or updating an R2 Bucket
14
20
  */
15
- export interface BucketProps {
21
+ export interface BucketProps extends CloudflareApiOptions {
16
22
  /**
17
23
  * Name of the bucket
18
24
  * Names can only contain lowercase letters (a-z), numbers (0-9), and hyphens (-)
@@ -28,6 +34,12 @@ export interface BucketProps {
28
34
  */
29
35
  locationHint?: string;
30
36
 
37
+ /**
38
+ * Optional storage class for the bucket
39
+ * Indicates the storage class for the bucket
40
+ */
41
+ storageClass?: "Standard" | "InfrequentAccess";
42
+
31
43
  /**
32
44
  * Optional jurisdiction for the bucket
33
45
  * Determines the regulatory jurisdiction the bucket data falls under
@@ -55,50 +67,62 @@ export interface BucketProps {
55
67
  empty?: boolean;
56
68
 
57
69
  /**
58
- * API Token to use for the bucket
70
+ * Whether to adopt an existing bucket
59
71
  */
60
- apiToken?: Secret;
72
+ adopt?: boolean;
61
73
 
62
74
  /**
63
- * API Key to use for the bucket
75
+ * CORS rules for the bucket
64
76
  */
65
- apiKey?: Secret;
77
+ cors?: R2BucketCORSRule[];
66
78
 
67
79
  /**
68
- * Email to use for the bucket
80
+ * Whether to emulate the bucket locally when Alchemy is running in watch mode.
69
81
  */
70
- email?: string;
82
+ dev?: {
83
+ /**
84
+ * Whether to run the bucket remotely instead of locally
85
+ * @default false
86
+ */
87
+ remote?: boolean;
88
+ };
89
+ }
71
90
 
91
+ interface R2BucketCORSRule {
72
92
  /**
73
- * Account ID to use for the bucket
93
+ * Identifier for this rule.
74
94
  */
75
- accountId?: string;
95
+ id?: string;
76
96
 
77
97
  /**
78
- * Access Key to use for the bucket
98
+ * Object specifying allowed origins, methods and headers for this CORS rule.
79
99
  */
80
- accessKey?: Secret;
100
+ allowed: {
101
+ /**
102
+ * Specifies the value for the Access-Control-Allow-Methods header R2 sets when requesting objects in a bucket from a browser.
103
+ */
104
+ methods: ("GET" | "PUT" | "POST" | "DELETE" | "HEAD")[];
81
105
 
82
- /**
83
- * Secret Access Key to use for the bucket
84
- */
85
- secretAccessKey?: Secret;
106
+ /**
107
+ * Specifies the value for the Access-Control-Allow-Origin header R2 sets when requesting objects in a bucket from a browser.
108
+ */
109
+ origins: string[];
110
+
111
+ /**
112
+ * Specifies the value for the Access-Control-Allow-Headers header R2 sets when requesting objects in this bucket from a browser. Cross-origin requests that include custom headers (e.g. x-user-id) should specify these headers as AllowedHeaders.
113
+ */
114
+ headers?: string[];
115
+ };
86
116
 
87
117
  /**
88
- * Whether to adopt an existing bucket
118
+ * Specifies the headers that can be exposed back, and accessed by, the JavaScript making the cross-origin request. If you need to access headers beyond the safelisted response headers, such as Content-Encoding or cf-cache-status, you must specify it here.
89
119
  */
90
- adopt?: boolean;
120
+ exposeHeaders?: string[];
91
121
 
92
122
  /**
93
- * Whether to emulate the bucket locally when Alchemy is running in watch mode.
123
+ * Specifies the amount of time (in seconds) browsers are allowed to cache CORS preflight responses. Browsers may limit this to 2 hours or less, even if the maximum value (86400) is specified.
94
124
  */
95
- dev?: {
96
- /**
97
- * Whether to run the bucket remotely instead of locally
98
- * @default false
99
- */
100
- remote?: boolean;
101
- };
125
+ maxAgeSeconds?: number;
102
126
  }
103
127
 
104
128
  /**
@@ -125,6 +149,11 @@ export type R2BucketResource = Resource<"cloudflare::R2Bucket"> &
125
149
  * Time at which the bucket was created
126
150
  */
127
151
  creationDate: Date;
152
+
153
+ /**
154
+ * The `r2.dev` subdomain for the bucket, if `allowPublicAccess` is true
155
+ */
156
+ domain: string | undefined;
128
157
  };
129
158
 
130
159
  export function isBucket(resource: Resource): resource is R2BucketResource {
@@ -204,126 +233,93 @@ const R2BucketResource = Resource(
204
233
  ): Promise<R2BucketResource> {
205
234
  const api = await createCloudflareApi(props);
206
235
  const bucketName = props.name || this.id;
207
-
208
- if (this.phase === "delete") {
209
- if (props.delete !== false) {
210
- if (props.empty) {
211
- logger.log("Emptying R2 bucket:", bucketName);
212
- const r2Client = await createR2Client({
213
- ...props,
214
- accountId: api.accountId,
215
- accessKeyId: props.accessKey ?? this.output.accessKey,
216
- secretAccessKey:
217
- props.secretAccessKey ?? this.output.secretAccessKey,
218
- });
219
- // Empty the bucket first by deleting all objects
220
- await emptyBucket(r2Client, bucketName, props.jurisdiction);
236
+ const allowPublicAccess = props.allowPublicAccess === true;
237
+
238
+ switch (this.phase) {
239
+ case "create": {
240
+ const bucket = await createBucket(api, bucketName, props).catch(
241
+ async (err) => {
242
+ if (
243
+ err instanceof CloudflareApiError &&
244
+ err.status === 409 &&
245
+ props.adopt
246
+ ) {
247
+ return await getBucket(api, bucketName, props);
248
+ }
249
+ throw err;
250
+ },
251
+ );
252
+ const domain = await putManagedDomain(
253
+ api,
254
+ bucketName,
255
+ allowPublicAccess,
256
+ props.jurisdiction,
257
+ );
258
+ if (props.cors?.length) {
259
+ await putBucketCORS(api, bucketName, props);
221
260
  }
222
-
223
- await deleteBucket(api, bucketName, props);
261
+ return this({
262
+ name: bucketName,
263
+ location: bucket.location,
264
+ creationDate: new Date(bucket.creation_date),
265
+ jurisdiction: bucket.jurisdiction,
266
+ allowPublicAccess,
267
+ domain,
268
+ type: "r2_bucket",
269
+ accountId: api.accountId,
270
+ cors: props.cors,
271
+ dev: props.dev,
272
+ });
224
273
  }
225
-
226
- // Return void (a deleted bucket has no content)
227
- return this.destroy();
228
- }
229
- if (this.phase === "create") {
230
- try {
231
- await createBucket(api, bucketName, props);
232
- } catch (err) {
233
- if (err instanceof CloudflareApiError && err.status === 409) {
234
- if (!props.adopt) {
235
- throw err;
274
+ case "update": {
275
+ if (bucketName !== this.output.name) {
276
+ throw new Error(
277
+ `Cannot update R2Bucket name after creation. Bucket name is immutable. Before: ${this.output.name}, After: ${bucketName}`,
278
+ );
279
+ }
280
+ let domain = this.output.domain;
281
+ if (!!domain !== allowPublicAccess) {
282
+ domain = await putManagedDomain(
283
+ api,
284
+ bucketName,
285
+ allowPublicAccess,
286
+ props.jurisdiction,
287
+ );
288
+ }
289
+ if (!isDeepStrictEqual(this.output.cors ?? [], props.cors ?? [])) {
290
+ await putBucketCORS(api, bucketName, props);
291
+ }
292
+ return this({
293
+ ...this.output,
294
+ allowPublicAccess,
295
+ dev: props.dev,
296
+ cors: props.cors,
297
+ domain,
298
+ });
299
+ }
300
+ case "delete": {
301
+ if (props.delete !== false) {
302
+ if (props.empty) {
303
+ await emptyBucket(api, bucketName, props);
236
304
  }
237
- } else {
238
- throw err;
305
+ await deleteBucket(api, bucketName, props);
239
306
  }
307
+ return this.destroy();
240
308
  }
241
309
  }
242
-
243
- if (this.phase === "update" && bucketName !== this.output.name) {
244
- throw new Error(
245
- `Cannot update R2Bucket name after creation. Bucket name is immutable. Before: ${this.output.name}, After: ${bucketName}`,
246
- );
247
- }
248
-
249
- await updatePublicAccess(
250
- api,
251
- bucketName,
252
- props.allowPublicAccess === true,
253
- props.jurisdiction,
254
- );
255
-
256
- return this({
257
- name: bucketName,
258
- location: props.locationHint || "default",
259
- creationDate: new Date(),
260
- jurisdiction: props.jurisdiction || "default",
261
- type: "r2_bucket",
262
- accountId: api.accountId,
263
- dev: props.dev,
264
- });
265
310
  },
266
311
  );
267
312
 
268
313
  /**
269
- * Configuration for R2 client to connect to Cloudflare R2
314
+ * The bucket information returned from the Cloudflare REST API
315
+ * @see https://developers.cloudflare.com/api/node/resources/r2/subresources/buckets/models/bucket/#(schema)
270
316
  */
271
- export interface R2ClientConfig {
272
- accountId: string;
273
- accessKeyId?: Secret;
274
- secretAccessKey?: Secret;
275
- jurisdiction?: string;
276
- }
277
-
278
- type R2Client = AwsClient & { accountId: string };
279
-
280
- /**
281
- * Creates an aws4fetch client configured for Cloudflare R2
282
- *
283
- * @see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/
284
- */
285
- export function createR2Client(config?: R2ClientConfig): Promise<R2Client> {
286
- const accountId = config?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
287
- const accessKeyId =
288
- config?.accessKeyId?.unencrypted || process.env.R2_ACCESS_KEY_ID;
289
- const secretAccessKey =
290
- config?.secretAccessKey?.unencrypted || process.env.R2_SECRET_ACCESS_KEY;
291
-
292
- if (!accountId) {
293
- throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required");
294
- }
295
-
296
- if (!accessKeyId || !secretAccessKey) {
297
- throw new Error(
298
- "R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY environment variables are required",
299
- );
300
- }
301
-
302
- // Create aws4fetch client with Cloudflare R2 endpoint
303
- const client: any = new AwsClient({
304
- accessKeyId,
305
- secretAccessKey,
306
- service: "s3",
307
- region: "auto",
308
- });
309
- client.accountId = accountId;
310
- return client;
311
- }
312
-
313
- interface CloudflareBucketResponse {
314
- /**
315
- * The bucket information returned from the Cloudflare REST API
316
- * @see https://developers.cloudflare.com/api/node/resources/r2/subresources/buckets/models/bucket/#(schema)
317
- */
318
- result: {
319
- creation_date: string;
320
- location?: "apac" | "eeur" | "enam" | "weur" | "wnam" | "oc";
321
- name: string;
322
- storage_class?: "Standard" | "InfrequentAccess";
323
- };
324
- success: boolean;
325
- errors: Array<{ code: number; message: string }>;
326
- messages: string[];
317
+ interface R2BucketResult {
318
+ creation_date: string;
319
+ location: "apac" | "eeur" | "enam" | "weur" | "wnam" | "oc";
320
+ name: string;
321
+ storage_class: "Standard" | "InfrequentAccess";
322
+ jurisdiction: "default" | "eu" | "fedramp";
327
323
  }
328
324
 
329
325
  /**
@@ -334,24 +330,14 @@ interface CloudflareBucketResponse {
334
330
  * @returns Modified headers object
335
331
  */
336
332
  export function withJurisdiction(
337
- headers: Record<string, string>,
338
- props: BucketProps | { jurisdiction?: string } | string | undefined,
333
+ props: { jurisdiction?: string },
334
+ headers: Record<string, string> = {},
339
335
  ): Record<string, string> {
340
- // Clone the headers object to avoid modifying the original
341
- const result = { ...headers };
342
-
343
- let jurisdiction: string | undefined;
344
- if (typeof props === "string") {
345
- jurisdiction = props;
346
- } else if (props && "jurisdiction" in props) {
347
- jurisdiction = props.jurisdiction;
336
+ if (props.jurisdiction && props.jurisdiction !== "default") {
337
+ headers["cf-r2-jurisdiction"] = props.jurisdiction;
348
338
  }
349
339
 
350
- if (jurisdiction && jurisdiction !== "default") {
351
- result["cf-r2-jurisdiction"] = jurisdiction;
352
- }
353
-
354
- return result;
340
+ return headers;
355
341
  }
356
342
 
357
343
  /**
@@ -361,28 +347,12 @@ export async function getBucket(
361
347
  api: CloudflareApi,
362
348
  bucketName: string,
363
349
  props: BucketProps = {},
364
- ): Promise<CloudflareBucketResponse> {
365
- const headers = withJurisdiction({}, props);
366
- const getResponse = await api.get(
367
- `/accounts/${api.accountId}/r2/buckets/${bucketName}`,
368
- { headers },
369
- );
370
-
371
- if (!getResponse.ok) {
372
- return await handleApiError(getResponse, "get", "R2 bucket", bucketName);
373
- }
374
-
375
- if (getResponse.status === 200) {
376
- return (await getResponse.json()) as CloudflareBucketResponse;
377
- }
378
-
379
- const errorData: any = await getResponse.json().catch(() => ({
380
- errors: [{ message: getResponse.statusText }],
381
- }));
382
-
383
- throw new CloudflareApiError(
384
- `Error getting R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || getResponse.statusText}`,
385
- getResponse,
350
+ ): Promise<R2BucketResult> {
351
+ return await extractCloudflareResult<R2BucketResult>(
352
+ `get R2 bucket "${bucketName}"`,
353
+ api.get(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, {
354
+ headers: withJurisdiction(props),
355
+ }),
386
356
  );
387
357
  }
388
358
 
@@ -393,34 +363,21 @@ export async function createBucket(
393
363
  api: CloudflareApi,
394
364
  bucketName: string,
395
365
  props: BucketProps = {},
396
- ): Promise<CloudflareBucketResponse> {
397
- // Create new R2 bucket
398
- const createPayload: any = {
399
- name: bucketName,
400
- };
401
-
402
- if (props.locationHint) {
403
- createPayload.location_hint = props.locationHint;
404
- }
405
-
406
- const headers = withJurisdiction({}, props);
407
-
408
- const createResponse = await api.post(
409
- `/accounts/${api.accountId}/r2/buckets`,
410
- createPayload,
411
- { headers },
366
+ ): Promise<R2BucketResult> {
367
+ return await extractCloudflareResult<R2BucketResult>(
368
+ `create R2 bucket "${bucketName}"`,
369
+ api.post(
370
+ `/accounts/${api.accountId}/r2/buckets`,
371
+ {
372
+ name: bucketName,
373
+ locationHint: props.locationHint,
374
+ storageClass: props.storageClass,
375
+ },
376
+ {
377
+ headers: withJurisdiction(props),
378
+ },
379
+ ),
412
380
  );
413
-
414
- if (!createResponse.ok) {
415
- return await handleApiError(
416
- createResponse,
417
- "creating",
418
- "R2 bucket",
419
- bucketName,
420
- );
421
- }
422
-
423
- return (await createResponse.json()) as CloudflareBucketResponse;
424
381
  }
425
382
 
426
383
  /**
@@ -430,195 +387,46 @@ export async function deleteBucket(
430
387
  api: CloudflareApi,
431
388
  bucketName: string,
432
389
  props: BucketProps,
433
- ): Promise<void> {
434
- // Delete R2 bucket
435
- const headers = withJurisdiction({}, props);
436
-
437
- const deleteResponse = await api.delete(
438
- `/accounts/${api.accountId}/r2/buckets/${bucketName}`,
439
- { headers },
440
- );
441
-
442
- if (!deleteResponse.ok && deleteResponse.status !== 404) {
443
- const errorData: any = await deleteResponse.json().catch(() => ({
444
- errors: [{ message: deleteResponse.statusText }],
445
- }));
446
- throw new CloudflareApiError(
447
- `Error deleting R2 bucket '${bucketName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
448
- deleteResponse,
449
- );
450
- }
451
- }
452
-
453
- /**
454
- * List objects in an R2 bucket
455
- *
456
- * @param r2 R2Client instance
457
- * @param bucketName Name of the bucket
458
- * @param continuationToken Optional token for pagination
459
- * @param jurisdiction Optional jurisdiction for the bucket
460
- * @returns Object containing the list of objects and the next continuation token
461
- */
462
- export async function listObjects(
463
- r2: R2Client,
464
- bucketName: string,
465
- continuationToken?: string,
466
- jurisdiction?: string,
467
- ): Promise<{ objects: { Key: string }[]; continuationToken?: string }> {
468
- // List objects in the bucket
469
- const url = new URL(
470
- `https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}`,
471
- );
472
- if (continuationToken) {
473
- url.searchParams.set("continuation-token", continuationToken);
474
- }
475
- url.searchParams.set("list-type", "2");
476
-
477
- const headers = withJurisdiction({}, jurisdiction);
478
-
479
- const listResponse = await r2.fetch(url.toString(), { headers });
480
- if (!listResponse.ok) {
481
- throw new CloudflareApiError(
482
- `Failed to list objects: ${listResponse.statusText}`,
483
- listResponse,
484
- );
485
- }
486
-
487
- const responseText = await listResponse.text();
488
-
489
- // Extract objects from XML response using regex
490
- const keyRegex = /<Key>([^<]+)<\/Key>/g;
491
- const objects: { Key: string }[] = [];
492
- let match;
493
- while ((match = keyRegex.exec(responseText)) !== null) {
494
- objects.push({ Key: match[1] });
495
- }
496
-
497
- // Get continuation token if present using regex
498
- const tokenMatch =
499
- /<NextContinuationToken>([^<]+)<\/NextContinuationToken>/.exec(
500
- responseText,
501
- );
502
- const nextContinuationToken = tokenMatch ? tokenMatch[1] : undefined;
503
-
504
- return { objects, continuationToken: nextContinuationToken };
505
- }
506
-
507
- /**
508
- * Helper function to empty a bucket by deleting all objects
509
- */
510
- export async function emptyBucket(
511
- r2: R2Client,
512
- bucketName: string,
513
- jurisdiction?: string,
514
- ): Promise<void> {
515
- let continuationToken: string | undefined;
516
- let totalDeleted = 0;
517
-
390
+ ) {
518
391
  try {
519
- do {
520
- logger.log(`Listing objects in bucket ${bucketName}`);
521
- // List objects in the bucket
522
- const { objects, continuationToken: nextToken } = await listObjects(
523
- r2,
524
- bucketName,
525
- continuationToken,
526
- jurisdiction,
527
- );
528
-
529
- continuationToken = nextToken;
530
-
531
- logger.log(`Found ${objects.length} objects in bucket ${bucketName}`);
532
-
533
- // Delete objects in batches
534
- if (objects.length > 0) {
535
- // Process delete in batches of 1000 (S3 limit)
536
- for (let i = 0; i < objects.length; i += 1000) {
537
- const batch = objects.slice(i, i + 1000);
538
-
539
- // Create DeleteObjects request XML
540
- const deleteXml = `
541
- <Delete>
542
- ${batch.map((obj) => `<Object><Key>${obj.Key}</Key></Object>`).join("")}
543
- </Delete>
544
- `;
545
-
546
- const deleteUrl = new URL(
547
- `https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}?delete`,
548
- );
549
-
550
- logger.log(
551
- `Deleting ${batch.length} objects from bucket ${bucketName}`,
552
- );
553
-
554
- const headers = withJurisdiction(
555
- { "Content-Type": "application/xml" },
556
- jurisdiction,
557
- );
558
-
559
- const deleteResponse = await r2.fetch(deleteUrl.toString(), {
560
- method: "POST",
561
- body: deleteXml,
562
- headers,
563
- });
564
-
565
- if (!deleteResponse.ok) {
566
- throw new CloudflareApiError(
567
- `Failed to delete objects: ${deleteResponse.statusText}`,
568
- deleteResponse,
569
- );
570
- }
571
-
572
- totalDeleted += batch.length;
573
- }
574
- }
575
- } while (continuationToken);
576
-
577
- logger.log(
578
- `Successfully emptied bucket ${bucketName}, deleted ${totalDeleted} objects total`,
392
+ await extractCloudflareResult(
393
+ `delete R2 bucket "${bucketName}"`,
394
+ api.delete(`/accounts/${api.accountId}/r2/buckets/${bucketName}`, {
395
+ headers: withJurisdiction(props),
396
+ }),
579
397
  );
580
398
  } catch (error) {
581
399
  if (error instanceof CloudflareApiError && error.status === 404) {
582
- // the bucket was not found
583
400
  return;
584
401
  }
585
- logger.error(`Failed to empty bucket ${bucketName}:`, error);
586
402
  throw error;
587
403
  }
588
404
  }
589
405
 
590
406
  /**
591
- * Update public access setting for a bucket
592
- *
593
- * This operation is not available through the S3 API for R2,
594
- * so we still use the Cloudflare API directly.
407
+ * Update the managed domain setting for a bucket
595
408
  */
596
- export async function updatePublicAccess(
409
+ export async function putManagedDomain(
597
410
  api: CloudflareApi,
598
411
  bucketName: string,
599
- allowPublicAccess: boolean,
412
+ enabled: boolean,
600
413
  jurisdiction?: string,
601
- ): Promise<void> {
602
- const headers = withJurisdiction({}, jurisdiction);
603
-
604
- await withExponentialBackoff(
414
+ ) {
415
+ return await withExponentialBackoff(
605
416
  async () => {
606
- const response = await api.put(
607
- `/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`,
608
- {
609
- enabled: allowPublicAccess,
610
- },
611
- { headers },
417
+ const result = await extractCloudflareResult<{
418
+ bucketId: string;
419
+ domain: string;
420
+ enabled: boolean;
421
+ }>(
422
+ `put R2 bucket managed domain for "${bucketName}"`,
423
+ api.put(
424
+ `/accounts/${api.accountId}/r2/buckets/${bucketName}/domains/managed`,
425
+ { enabled },
426
+ { headers: withJurisdiction({ jurisdiction }) },
427
+ ),
612
428
  );
613
-
614
- if (!response.ok) {
615
- await handleApiError(
616
- response,
617
- "updating public access for",
618
- "R2 bucket",
619
- bucketName,
620
- );
621
- }
429
+ return result.enabled ? result.domain : undefined;
622
430
  },
623
431
  (err) => err.status === 404,
624
432
  10,
@@ -627,76 +435,72 @@ export async function updatePublicAccess(
627
435
  }
628
436
 
629
437
  /**
630
- * Set CORS configuration for a bucket using aws4fetch
438
+ * Delete all objects in a bucket
631
439
  */
632
- export async function setCorsConfiguration(
633
- r2: R2Client,
440
+ async function emptyBucket(
441
+ api: CloudflareApi,
634
442
  bucketName: string,
635
- allowedOrigins: string[] = ["*"],
636
- allowedMethods: string[] = ["GET", "HEAD", "PUT", "POST", "DELETE"],
637
- allowedHeaders: string[] = ["*"],
638
- maxAgeSeconds = 3600,
639
- jurisdiction?: string,
640
- ): Promise<void> {
641
- try {
642
- // Construct CORS XML configuration
643
- const corsXml = `
644
- <CORSConfiguration>
645
- <CORSRule>
646
- ${allowedOrigins.map((origin) => `<AllowedOrigin>${origin}</AllowedOrigin>`).join("")}
647
- ${allowedMethods.map((method) => `<AllowedMethod>${method}</AllowedMethod>`).join("")}
648
- ${allowedHeaders.map((header) => `<AllowedHeader>${header}</AllowedHeader>`).join("")}
649
- <ExposeHeader>ETag</ExposeHeader>
650
- <MaxAgeSeconds>${maxAgeSeconds}</MaxAgeSeconds>
651
- </CORSRule>
652
- </CORSConfiguration>
653
- `;
654
-
655
- const url = new URL(
656
- `https://${r2.accountId}.r2.cloudflarestorage.com/${bucketName}?cors`,
657
- );
658
-
659
- const headers = withJurisdiction(
660
- { "Content-Type": "application/xml" },
661
- jurisdiction,
443
+ props: BucketProps,
444
+ ) {
445
+ let batch: Promise<unknown>[] = [];
446
+ for await (const key of listObjects(api, bucketName, props)) {
447
+ batch.push(
448
+ extractCloudflareResult(
449
+ `delete R2 object "${key}"`,
450
+ api.delete(
451
+ `/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`,
452
+ { headers: withJurisdiction(props) },
453
+ ),
454
+ ),
662
455
  );
663
-
664
- const response = await r2.fetch(url.toString(), {
665
- method: "PUT",
666
- body: corsXml,
667
- headers,
668
- });
669
-
670
- if (!response.ok) {
671
- throw new CloudflareApiError(
672
- `Failed to set CORS configuration: ${response.statusText}`,
673
- response,
674
- );
456
+ if (batch.length >= 10) {
457
+ // this is an arbitary batch size, open to feedback
458
+ await Promise.all(batch);
459
+ batch = [];
675
460
  }
676
-
677
- logger.log(`Successfully set CORS configuration for bucket ${bucketName}`);
678
- } catch (error) {
679
- logger.error(
680
- `Failed to set CORS configuration for bucket ${bucketName}:`,
681
- error,
682
- );
683
- throw error;
684
461
  }
462
+ await Promise.all(batch);
685
463
  }
686
464
 
687
465
  /**
688
- * Information about an R2 bucket returned by list operations
466
+ * Returns an async iterable of all object keys in a bucket,
467
+ * handling pagination automatically.
689
468
  */
690
- export interface R2BucketInfo {
691
- /**
692
- * Name of the bucket
693
- */
694
- Name: string;
695
-
696
- /**
697
- * Creation date of the bucket
698
- */
699
- CreationDate: Date;
469
+ export async function* listObjects(
470
+ api: CloudflareApi,
471
+ bucketName: string,
472
+ props: { jurisdiction?: string },
473
+ cursor?: string,
474
+ ): AsyncGenerator<string> {
475
+ const params = new URLSearchParams({
476
+ per_page: "1000",
477
+ });
478
+ if (cursor) {
479
+ params.set("cursor", cursor);
480
+ }
481
+ const response = await api.get(
482
+ `/accounts/${api.accountId}/r2/buckets/${bucketName}/objects?${params.toString()}`,
483
+ { headers: withJurisdiction(props) },
484
+ );
485
+ if (!response.ok) {
486
+ throw new Error(
487
+ `Failed to list objects in bucket "${bucketName}": ${await extractCloudflareError(response)}`,
488
+ );
489
+ }
490
+ const json: {
491
+ result: { key: string }[];
492
+ result_info?: {
493
+ cursor: string;
494
+ is_truncated: boolean;
495
+ per_page: number;
496
+ };
497
+ } = await response.json();
498
+ for (const object of json.result) {
499
+ yield object.key;
500
+ }
501
+ if (json.result_info?.is_truncated && json.result_info.cursor) {
502
+ yield* listObjects(api, bucketName, props, json.result_info.cursor);
503
+ }
700
504
  }
701
505
 
702
506
  /**
@@ -715,7 +519,7 @@ export async function listBuckets(
715
519
  direction?: "asc" | "desc";
716
520
  jurisdiction?: string;
717
521
  } = {},
718
- ): Promise<R2BucketInfo[]> {
522
+ ) {
719
523
  // Build query parameters
720
524
  const params = new URLSearchParams();
721
525
 
@@ -738,39 +542,43 @@ export async function listBuckets(
738
542
  // Build URL with query parameters
739
543
  const path = `/accounts/${api.accountId}/r2/buckets${params.toString() ? `?${params.toString()}` : ""}`;
740
544
 
741
- // Set jurisdiction header if provided
742
- const headers = withJurisdiction({}, options.jurisdiction);
743
-
744
545
  // Make the API request
745
- const response = await api.get(path, { headers });
746
-
747
- if (!response.ok) {
748
- throw new CloudflareApiError(
749
- `Failed to list buckets: ${response.statusText}`,
750
- response,
751
- );
752
- }
546
+ const result = await extractCloudflareResult<{
547
+ buckets: { name: string; creation_date: string }[];
548
+ }>(
549
+ "list R2 buckets",
550
+ api.get(path, {
551
+ headers: withJurisdiction(options),
552
+ }),
553
+ );
554
+ return result.buckets;
555
+ }
753
556
 
754
- const data = (await response.json()) as {
755
- success: boolean;
756
- errors?: Array<{ code: number; message: string }>;
757
- result?: {
758
- buckets: Array<{
759
- name: string;
760
- creation_date: string;
761
- location?: string;
762
- }>;
557
+ export async function putBucketCORS(
558
+ api: CloudflareApi,
559
+ bucketName: string,
560
+ props: BucketProps,
561
+ ) {
562
+ let request: RequestInit;
563
+ if (props.cors?.length) {
564
+ request = {
565
+ method: "PUT",
566
+ body: JSON.stringify({ rules: props.cors }),
567
+ headers: withJurisdiction(props, {
568
+ "Content-Type": "application/json",
569
+ }),
570
+ };
571
+ } else {
572
+ request = {
573
+ method: "DELETE",
574
+ headers: withJurisdiction(props),
763
575
  };
764
- };
765
-
766
- if (!data.success) {
767
- const errorMessage = data.errors?.[0]?.message || "Unknown error";
768
- throw new Error(`Failed to list buckets: ${errorMessage}`);
769
576
  }
770
-
771
- // Transform API response to R2BucketInfo objects
772
- return (data.result?.buckets || []).map((bucket) => ({
773
- Name: bucket.name,
774
- CreationDate: new Date(bucket.creation_date),
775
- }));
577
+ await extractCloudflareResult(
578
+ `${request.method} R2 bucket CORS rules for "${bucketName}"`,
579
+ api.fetch(
580
+ `/accounts/${api.accountId}/r2/buckets/${bucketName}/cors`,
581
+ request,
582
+ ),
583
+ );
776
584
  }