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.
- package/lib/cloudflare/api-response.d.ts +2 -2
- package/lib/cloudflare/api-response.d.ts.map +1 -1
- package/lib/cloudflare/api-response.js +2 -1
- package/lib/cloudflare/api-response.js.map +1 -1
- package/lib/cloudflare/bucket.d.ts +70 -105
- package/lib/cloudflare/bucket.d.ts.map +1 -1
- package/lib/cloudflare/bucket.js +127 -244
- package/lib/cloudflare/bucket.js.map +1 -1
- package/lib/cloudflare/nuxt.js +1 -1
- package/lib/cloudflare/nuxt.js.map +1 -1
- package/lib/cloudflare/orange.d.ts.map +1 -1
- package/lib/cloudflare/orange.js +4 -1
- package/lib/cloudflare/orange.js.map +1 -1
- package/lib/cloudflare/redwood.d.ts.map +1 -1
- package/lib/cloudflare/redwood.js +5 -1
- package/lib/cloudflare/redwood.js.map +1 -1
- package/lib/cloudflare/vite.d.ts.map +1 -1
- package/lib/cloudflare/vite.js +2 -10
- package/lib/cloudflare/vite.js.map +1 -1
- package/lib/cloudflare/website.d.ts.map +1 -1
- package/lib/cloudflare/website.js +1 -0
- package/lib/cloudflare/website.js.map +1 -1
- package/lib/cloudflare/worker.d.ts.map +1 -1
- package/lib/cloudflare/worker.js +1 -0
- package/lib/cloudflare/worker.js.map +1 -1
- package/lib/os/exec.d.ts +1 -1
- package/lib/os/exec.d.ts.map +1 -1
- package/lib/os/exec.js +1 -1
- package/lib/os/exec.js.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/api-response.ts +7 -3
- package/src/cloudflare/bucket.ts +276 -468
- package/src/cloudflare/nuxt.ts +1 -1
- package/src/cloudflare/orange.ts +4 -1
- package/src/cloudflare/redwood.ts +6 -1
- package/src/cloudflare/vite.ts +2 -10
- package/src/cloudflare/website.ts +1 -0
- package/src/cloudflare/worker.ts +2 -1
- package/src/os/exec.ts +2 -2
package/src/cloudflare/bucket.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import {
|
|
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
|
|
9
|
-
import {
|
|
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
|
-
*
|
|
70
|
+
* Whether to adopt an existing bucket
|
|
59
71
|
*/
|
|
60
|
-
|
|
72
|
+
adopt?: boolean;
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
|
-
*
|
|
75
|
+
* CORS rules for the bucket
|
|
64
76
|
*/
|
|
65
|
-
|
|
77
|
+
cors?: R2BucketCORSRule[];
|
|
66
78
|
|
|
67
79
|
/**
|
|
68
|
-
*
|
|
80
|
+
* Whether to emulate the bucket locally when Alchemy is running in watch mode.
|
|
69
81
|
*/
|
|
70
|
-
|
|
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
|
-
*
|
|
93
|
+
* Identifier for this rule.
|
|
74
94
|
*/
|
|
75
|
-
|
|
95
|
+
id?: string;
|
|
76
96
|
|
|
77
97
|
/**
|
|
78
|
-
*
|
|
98
|
+
* Object specifying allowed origins, methods and headers for this CORS rule.
|
|
79
99
|
*/
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
120
|
+
exposeHeaders?: string[];
|
|
91
121
|
|
|
92
122
|
/**
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
338
|
-
|
|
333
|
+
props: { jurisdiction?: string },
|
|
334
|
+
headers: Record<string, string> = {},
|
|
339
335
|
): Record<string, string> {
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
`/accounts/${api.accountId}/r2/buckets/${bucketName}`,
|
|
368
|
-
|
|
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<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
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
|
|
409
|
+
export async function putManagedDomain(
|
|
597
410
|
api: CloudflareApi,
|
|
598
411
|
bucketName: string,
|
|
599
|
-
|
|
412
|
+
enabled: boolean,
|
|
600
413
|
jurisdiction?: string,
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
await withExponentialBackoff(
|
|
414
|
+
) {
|
|
415
|
+
return await withExponentialBackoff(
|
|
605
416
|
async () => {
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
*
|
|
438
|
+
* Delete all objects in a bucket
|
|
631
439
|
*/
|
|
632
|
-
|
|
633
|
-
|
|
440
|
+
async function emptyBucket(
|
|
441
|
+
api: CloudflareApi,
|
|
634
442
|
bucketName: string,
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
*
|
|
466
|
+
* Returns an async iterable of all object keys in a bucket,
|
|
467
|
+
* handling pagination automatically.
|
|
689
468
|
*/
|
|
690
|
-
export
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
)
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
}
|