1import * as r2 from "@distilled.cloud/cloudflare/r2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5
6import { deepEqual, isResolved } from "../../Diff.ts";
7import * as ProviderLayer from "../../Local/ProviderLayer.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { isResourceOfType, Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import { localAccountId } from "../LocalAccount.ts";
13import { generateLocalId } from "../LocalRuntime.ts";
14import type * as Cloudflare from "../Providers.ts";
15import * as Zone from "../Zone/index.ts";
16
17export const isBucket = (value: any): value is Bucket =>
18 isResourceOfType(value, "Cloudflare.R2.Bucket");
19
20export type BucketName = string;
21
22export type BucketCustomDomainZone = Zone.Reference;
23
24export type BucketCustomDomain = {
25 /**
26 * Custom domain name to attach to the bucket.
27 */
28 name: string;
29 /**
30 * Zone that contains the custom domain. If omitted, the zone is inferred
31 * from `domain`. Pass a zone ID string, a hostname in the zone, or any object
32 * with a `zoneId` attribute such as `Cloudflare.Zone.Zone`.
33 */
34 zone?: BucketCustomDomainZone;
35 /**
36 * Whether public bucket access is enabled at this custom domain.
37 * @default true
38 */
39 enabled?: boolean;
40 /**
41 * Allowlist of TLS ciphers in BoringSSL format.
42 */
43 ciphers?: string[];
44 /**
45 * Minimum TLS version accepted by the custom domain.
46 * @default "1.0"
47 */
48 minTLS?: "1.0" | "1.1" | "1.2" | "1.3";
49};
50
51export type BucketLifecycleCondition =
52 | {
53 type: "Age";
54 /**
55 * Maximum age of an object, in seconds, before the rule's action applies.
56 */
57 maxAge: number;
58 }
59 | {
60 type: "Date";
61 /**
62 * Absolute date (ISO 8601) at which the rule's action applies.
63 */
64 date: string;
65 };
66
67export type BucketLifecycleRule = {
68 /**
69 * Unique identifier for the rule within the bucket.
70 */
71 id: string;
72 /**
73 * Whether the rule is enabled.
74 * @default true
75 */
76 enabled?: boolean;
77 /**
78 * Object key prefix the rule applies to. Use `""` (or omit) to match all
79 * objects in the bucket.
80 * @default ""
81 */
82 prefix?: string;
83 /**
84 * Abort incomplete multipart uploads after the configured age.
85 */
86 abortMultipartUploadsTransition?: {
87 condition?: { type: "Age"; maxAge: number };
88 };
89 /**
90 * Delete matching objects after the configured age or on a specific date.
91 */
92 deleteObjectsTransition?: {
93 condition?: BucketLifecycleCondition;
94 };
95 /**
96 * Transition matching objects to a different storage class. Cloudflare R2
97 * only supports transitioning to `InfrequentAccess` today.
98 */
99 storageClassTransitions?: {
100 condition: BucketLifecycleCondition;
101 storageClass: "InfrequentAccess";
102 }[];
103};
104
105export type BucketCorsRule = {
106 /**
107 * Optional label for this rule, shown in the Cloudflare dashboard. Not
108 * used to correlate rules across updates — the CORS configuration is
109 * always replaced as a whole.
110 */
111 id?: string;
112 /**
113 * HTTP methods browsers are allowed to use in cross-origin requests.
114 */
115 allowedMethods: ("GET" | "PUT" | "POST" | "DELETE" | "HEAD")[];
116 /**
117 * Origins allowed to make cross-origin requests, e.g.
118 * `"https://example.com"`. Use `"*"` to allow any origin.
119 */
120 allowedOrigins: string[];
121 /**
122 * Request headers browsers are allowed to send, e.g. `"range"` for
123 * range reads. If omitted, only simple headers are allowed.
124 */
125 allowedHeaders?: string[];
126 /**
127 * Response headers the browser is allowed to expose to the requesting
128 * JavaScript, e.g. `"etag"` or `"content-range"`.
129 */
130 exposeHeaders?: string[];
131 /**
132 * How long (in seconds) browsers may cache CORS preflight responses.
133 * Browsers may cap this at 2 hours or less, even if 86400 is specified.
134 */
135 maxAgeSeconds?: number;
136};
137
138export type BucketProps = {
139 /**
140 * Name of the bucket. If omitted, a unique name will be generated.
141 * @default ${app}-${stage}-${id}
142 */
143 name?: string;
144 /**
145 * Storage class for newly uploaded objects.
146 * @default "Standard"
147 */
148 storageClass?: Bucket.StorageClass;
149 /**
150 * Jurisdiction where objects in this bucket are guaranteed to be stored.
151 * @default "default"
152 */
153 jurisdiction?: Bucket.Jurisdiction;
154 /**
155 * Location hint for the bucket.
156 */
157 locationHint?: Bucket.Location;
158 /**
159 * Custom domains to attach to the bucket. Pass an empty array (or omit)
160 * to remove all custom domains.
161 */
162 domains?: BucketCustomDomain[];
163 /**
164 * Whether the bucket is publicly readable at Cloudflare's managed
165 * `r2.dev` domain. Cloudflare's default is off; omit or pass `false`
166 * to disable it.
167 *
168 * Once enabled, the hostname is reported on `publicDomain`. This
169 * endpoint is rate-limited and intended for non-production use — use
170 * `domains` for production public access.
171 *
172 * @default false
173 */
174 publicAccess?: boolean;
175 /**
176 * Object lifecycle rules applied to the bucket. Pass an empty array (or
177 * omit) to clear all lifecycle rules. See the Cloudflare R2 docs for
178 * supported transitions.
179 */
180 lifecycleRules?: BucketLifecycleRule[];
181 /**
182 * CORS rules applied to the bucket, controlling which cross-origin
183 * browser requests are allowed against the bucket's public or S3 API
184 * endpoints. Pass an empty array (or omit) to remove the CORS
185 * configuration.
186 */
187 cors?: BucketCorsRule[];
188 /**
189 * Allow alchemy to delete every object in the bucket when the bucket
190 * itself is deleted.
191 *
192 * R2 refuses to delete a bucket that still has objects in it — that
193 * refusal is the last line of defense for your data, so alchemy does not
194 * bypass it by default: destroying a non-empty bucket fails with
195 * `BucketNotEmpty` and both the bucket and its objects survive. Set this
196 * to `true` for buckets whose contents are disposable (caches, previews,
197 * test fixtures).
198 *
199 * `alchemy unsafe nuke` empties buckets regardless, since it is an
200 * explicitly operator-confirmed account teardown.
201 *
202 * @default false
203 */
204 forceDestroy?: boolean;
205};
206
207export type Bucket = Resource<
208 "Cloudflare.R2.Bucket",
209 BucketProps,
210 {
211 bucketName: BucketName;
212 storageClass: Bucket.StorageClass;
213 jurisdiction: Bucket.Jurisdiction;
214 location: Bucket.Location | undefined;
215 accountId: string;
216 domains: Bucket.CustomDomain[];
217 lifecycleRules: Bucket.LifecycleRule[];
218 cors: Bucket.CorsRule[];
219 /**
220 * Hostname of the bucket's Cloudflare-managed `r2.dev` domain.
221 * Set only while `publicAccess` is enabled; `undefined` when
222 * disabled (the domain still exists but serves 401).
223 */
224 publicDomain: string | undefined;
225 },
226 never,
227 Cloudflare.Providers
228>;
229
230/**
231 * A Cloudflare R2 object storage bucket with S3-compatible API.
232 *
233 * R2 provides zero-egress-fee object storage. Create a bucket as a resource,
234 * then bind it to a Worker to read and write objects at runtime.
235 * ### Creating a Bucket
236 * **Example:** Basic R2 bucket
237 * ```typescript
238 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket");
239 * ```
240 *
241 * **Example:** Bucket with location hint
242 * ```typescript
243 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
244 * locationHint: "wnam",
245 * });
246 * ```
247 *
248 * ### Binding to a Worker
249 * **Example:** Reading and writing objects
250 * ```typescript
251 * const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
252 *
253 * // Write an object
254 * yield* bucket.put("hello.txt", "Hello, World!");
255 *
256 * // Read an object
257 * const object = yield* bucket.get("hello.txt");
258 * if (object) {
259 * const text = yield* object.text();
260 * }
261 * ```
262 *
263 * **Example:** Streaming upload with content length
264 * ```typescript
265 * const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
266 *
267 * yield* bucket.put("upload.bin", request.stream, {
268 * contentLength: Number(request.headers["content-length"] ?? 0),
269 * });
270 * ```
271 *
272 * ### Custom Domains
273 *
274 * Attach one or more custom domains to serve bucket objects from a hostname
275 * you control. The domain's zone must already exist in your Cloudflare
276 * account; the zone is inferred from the hostname when omitted, or you can
277 * pass a `Cloudflare.Zone.Zone` resource, a zone ID, or any hostname inside the
278 * zone via the `zone` field.
279 *
280 * **Example:** Single custom domain
281 * ```typescript
282 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
283 * domains: [{ name: "assets.example.com" }],
284 * });
285 * ```
286 *
287 * **Example:** Multiple custom domains
288 * ```typescript
289 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
290 * domains: [
291 * { name: "assets.example.com" },
292 * { name: "static.example.com" },
293 * ],
294 * });
295 * ```
296 *
297 * **Example:** Disable a custom domain without removing it
298 * ```typescript
299 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
300 * domains: [{ name: "assets.example.com", enabled: false }],
301 * });
302 * ```
303 *
304 * **Example:** Custom domain with explicit zone and TLS settings
305 * ```typescript
306 * const zone = yield* Cloudflare.Zone.Zone("ExampleZone", {
307 * name: "example.com",
308 * });
309 *
310 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
311 * domains: [
312 * {
313 * name: "assets.example.com",
314 * zone,
315 * minTLS: "1.2",
316 * },
317 * ],
318 * });
319 * ```
320 *
321 * ### Public Development URL
322 *
323 * Enable Cloudflare's managed `r2.dev` domain so objects are publicly
324 * readable without attaching a custom domain. The hostname is reported
325 * on `publicDomain`. This endpoint is rate-limited and intended for
326 * non-production use — use `domains` for production public access.
327 *
328 * **Example:** Enable public access at r2.dev
329 * ```typescript
330 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
331 * publicAccess: true,
332 * });
333 * // objects are at https://${bucket.publicDomain}/<key>
334 * ```
335 *
336 * **Example:** Disable public access
337 * ```typescript
338 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
339 * publicAccess: false,
340 * });
341 * ```
342 *
343 * ### Object Lifecycle Rules
344 *
345 * Configure lifecycle rules to automatically delete objects, abort
346 * incomplete multipart uploads, or transition objects to InfrequentAccess
347 * storage. Pass an empty array (or omit) to clear all rules. See the
348 * [Cloudflare R2 docs](https://developers.cloudflare.com/r2/buckets/object-lifecycles/)
349 * for details and limits (max 1000 rules per bucket).
350 *
351 * **Example:** Delete objects 30 days after upload
352 * ```typescript
353 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
354 * lifecycleRules: [
355 * {
356 * id: "expire-old-objects",
357 * deleteObjectsTransition: {
358 * condition: { type: "Age", maxAge: 60 * 60 * 24 * 30 },
359 * },
360 * },
361 * ],
362 * });
363 * ```
364 *
365 * **Example:** Transition to InfrequentAccess after 60 days, delete after 365
366 * ```typescript
367 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
368 * lifecycleRules: [
369 * {
370 * id: "archive-then-delete",
371 * prefix: "logs/",
372 * storageClassTransitions: [
373 * {
374 * condition: { type: "Age", maxAge: 60 * 60 * 24 * 60 },
375 * storageClass: "InfrequentAccess",
376 * },
377 * ],
378 * deleteObjectsTransition: {
379 * condition: { type: "Age", maxAge: 60 * 60 * 24 * 365 },
380 * },
381 * },
382 * ],
383 * });
384 * ```
385 *
386 * **Example:** Abort incomplete multipart uploads after 7 days
387 * ```typescript
388 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
389 * lifecycleRules: [
390 * {
391 * id: "abort-stale-uploads",
392 * abortMultipartUploadsTransition: {
393 * condition: { type: "Age", maxAge: 60 * 60 * 24 * 7 },
394 * },
395 * },
396 * ],
397 * });
398 * ```
399 *
400 * ### CORS
401 *
402 * Configure CORS rules so browsers can make cross-origin requests against
403 * the bucket's public (custom domain / r2.dev) or S3 API endpoints. Pass an
404 * empty array (or omit) to remove the CORS configuration. See the
405 * [Cloudflare R2 docs](https://developers.cloudflare.com/r2/buckets/cors/)
406 * for details.
407 *
408 * **Example:** Allow cross-origin reads from any origin
409 * ```typescript
410 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
411 * cors: [
412 * {
413 * allowedMethods: ["GET", "HEAD"],
414 * allowedOrigins: ["*"],
415 * },
416 * ],
417 * });
418 * ```
419 *
420 * **Example:** Browser range reads (e.g. PMTiles map tiles)
421 * ```typescript
422 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
423 * domains: [{ name: "tiles.example.com" }],
424 * cors: [
425 * {
426 * allowedMethods: ["GET", "HEAD"],
427 * allowedOrigins: ["https://map.example.com"],
428 * allowedHeaders: ["range", "if-match"],
429 * exposeHeaders: ["etag", "content-range"],
430 * maxAgeSeconds: 3600,
431 * },
432 * ],
433 * });
434 * ```
435 *
436 * **Example:** Allow uploads from a web app
437 * ```typescript
438 * const bucket = yield* Cloudflare.R2.Bucket("MyBucket", {
439 * cors: [
440 * {
441 * allowedMethods: ["GET", "PUT", "POST"],
442 * allowedOrigins: ["https://app.example.com"],
443 * allowedHeaders: ["content-type"],
444 * exposeHeaders: ["etag"],
445 * },
446 * ],
447 * });
448 * ```
449 *
450 * ### Deleting a Bucket
451 *
452 * R2 refuses to delete a bucket that still has objects in it, and alchemy
453 * does not bypass that refusal: destroying a non-empty bucket fails with
454 * `BucketNotEmpty` and both the bucket and its objects survive. Opt into
455 * emptying the bucket first with `forceDestroy` for buckets whose contents
456 * are disposable.
457 *
458 * **Example:** Empty the bucket on destroy
459 * ```typescript
460 * const cache = yield* Cloudflare.R2.Bucket("Cache", {
461 * forceDestroy: true,
462 * });
463 * ```
464 *
465 * **Example:** Keep the bucket even when the stack goes away
466 * ```typescript
467 * import * as RemovalPolicy from "alchemy/RemovalPolicy";
468 *
469 * const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe(
470 * RemovalPolicy.retain(),
471 * );
472 * ```
473 *
474 * @resource
475 * @product R2
476 * @category Storage & Databases
477 */
478export const Bucket = Resource<Bucket>("Cloudflare.R2.Bucket", {
479 aliases: ["Cloudflare.R2Bucket"],
480});
481
482export declare namespace Bucket {
483 export type StorageClass = "Standard" | "InfrequentAccess";
484 export type Jurisdiction = "default" | "eu" | "fedramp";
485 export type Location = "apac" | "eeur" | "enam" | "weur" | "wnam" | "oc";
486 export type LifecycleRule = {
487 id: string;
488 enabled: boolean;
489 prefix: string;
490 abortMultipartUploadsTransition:
491 | { condition: { type: "Age"; maxAge: number } | undefined }
492 | undefined;
493 deleteObjectsTransition:
494 | { condition: BucketLifecycleCondition | undefined }
495 | undefined;
496 storageClassTransitions:
497 | {
498 condition: BucketLifecycleCondition;
499 storageClass: "InfrequentAccess";
500 }[]
501 | undefined;
502 };
503 export type CorsRule = {
504 id: string | undefined;
505 allowedMethods: ("GET" | "PUT" | "POST" | "DELETE" | "HEAD")[];
506 allowedOrigins: string[];
507 allowedHeaders: string[] | undefined;
508 exposeHeaders: string[] | undefined;
509 maxAgeSeconds: number | undefined;
510 };
511 export type CustomDomain = {
512 domain: string;
513 zoneId: string | undefined;
514 enabled: boolean;
515 ciphers: string[] | undefined;
516 minTLS: "1.0" | "1.1" | "1.2" | "1.3" | undefined;
517 status:
518 | {
519 ownership:
520 | "pending"
521 | "active"
522 | "deactivated"
523 | "blocked"
524 | "error"
525 | "unknown";
526 ssl:
527 | "initializing"
528 | "pending"
529 | "active"
530 | "deactivated"
531 | "error"
532 | "unknown";
533 }
534 | undefined;
535 };
536}
537
538export const ProviderLive = () =>
539 Provider.effect(
540 Bucket,
541 Effect.gen(function* () {
542 const emptyBucket = Effect.fn(function* (
543 bucketName: string,
544 jurisdiction: Bucket.Jurisdiction,
545 ) {
546 const { accountId } = yield* yield* CloudflareEnvironment;
547 return yield* r2.listObjects
548 .items({
549 accountId,
550 bucketName,
551 cfR2Jurisdiction: jurisdiction,
552 perPage: 1000,
553 })
554 .pipe(
555 Stream.filter(
556 (o): o is typeof o & { key: string } =>
557 typeof o.key === "string" && o.key !== "",
558 ),
559 Stream.map((o) => o.key),
560 Stream.runForEachArray((chunk) =>
561 r2.deleteObjects({
562 accountId,
563 bucketName,
564 cfR2Jurisdiction: jurisdiction,
565 body: [...chunk],
566 }),
567 ),
568 Effect.catchTag("NoSuchBucket", () => Effect.void),
569 );
570 });
571
572 const createBucketName = (id: string, name: string | undefined) =>
573 Effect.gen(function* () {
574 if (name) return name;
575 return (yield* createPhysicalName({
576 id,
577 maxLength: 63,
578 })).toLowerCase();
579 });
580
581 const normalizeLocation = (
582 location: string | undefined | null,
583 ): Bucket.Location | undefined => {
584 if (!location) return undefined;
585 return location.toLowerCase() as Bucket.Location;
586 };
587
588 const listCustomDomains = Effect.fn(function* (
589 bucketName: string,
590 jurisdiction: Bucket.Jurisdiction,
591 // `NoSuchBucket` after a *create* is endpoint-consistency lag worth
592 // retrying. During *enumeration* (`list`), a bucket that 404s is one a
593 // parallel suite just deleted — it's genuinely gone, so retrying the
594 // consistency schedule only burns ~3s per churned bucket and is what
595 // pushes an account-wide `list` past its timeout. Default to retrying
596 // (the reconcile path); opt out for enumeration.
597 options?: { retryMissing?: boolean },
598 ) {
599 const { accountId } = yield* yield* CloudflareEnvironment;
600 const fetch = r2.listBucketDomainCustoms({
601 accountId,
602 bucketName,
603 jurisdiction,
604 });
605 return yield* (
606 options?.retryMissing === false
607 ? fetch
608 : fetch.pipe(
609 Effect.retry({
610 while: (e) => e._tag === "NoSuchBucket",
611 schedule: r2BucketEndpointConsistencySchedule,
612 }),
613 )
614 ).pipe(
615 Effect.map((response) =>
616 response.domains.map(toCustomDomainAttributes),
617 ),
618 Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
619 );
620 });
621
622 const reconcileCustomDomains = (
623 bucketName: string,
624 jurisdiction: Bucket.Jurisdiction,
625 desired: BucketCustomDomain[],
626 previous: Bucket.CustomDomain[],
627 ) =>
628 Effect.gen(function* () {
629 const { accountId } = yield* yield* CloudflareEnvironment;
630 const observed = yield* listCustomDomains(bucketName, jurisdiction);
631 if (!observed) {
632 return yield* Effect.fail(
633 new Error(
634 `Cannot reconcile custom domains for missing R2 bucket "${bucketName}"`,
635 ),
636 );
637 }
638 const observedByDomain = new Map(
639 observed.map((domain) => [domain.domain, domain]),
640 );
641 const desiredDomains = new Set(desired.map((domain) => domain.name));
642
643 // Remove domains that are no longer desired. Domains that keep the
644 // same hostname but move zones are intentionally skipped here and
645 // handled in the per-domain flow below.
646 yield* Effect.forEach(
647 previous,
648 (previousDomain) =>
649 desiredDomains.has(previousDomain.domain)
650 ? Effect.void
651 : r2
652 .deleteBucketDomainCustom({
653 accountId,
654 bucketName,
655 domain: previousDomain.domain,
656 jurisdiction,
657 })
658 .pipe(
659 Effect.catchTag(
660 ["DomainNotFound", "NoSuchBucket"],
661 () => Effect.void,
662 ),
663 ),
664 { concurrency: "unbounded" },
665 );
666
667 const applied = yield* Effect.forEach(
668 desired,
669 (domain) =>
670 Effect.gen(function* () {
671 const zoneId = yield* Zone.resolveZoneId({
672 accountId,
673 zone: domain.zone,
674 hostname: domain.name,
675 });
676 const observedDomain = observedByDomain.get(domain.name);
677
678 if (
679 observedDomain &&
680 sameCustomDomainConfig(observedDomain, domain, zoneId)
681 ) {
682 return observedDomain;
683 }
684
685 if (observedDomain && observedDomain.zoneId !== zoneId) {
686 // Cloudflare does not mutate the zone for an existing custom
687 // domain. This is not a duplicate of the stale-domain prune
688 // above: the hostname is still desired, so that prune skips it
689 // and this branch deletes only to recreate it in the new zone.
690 yield* r2
691 .deleteBucketDomainCustom({
692 accountId,
693 bucketName,
694 domain: domain.name,
695 jurisdiction,
696 })
697 .pipe(
698 Effect.catchTag(
699 ["DomainNotFound", "NoSuchBucket"],
700 () => Effect.void,
701 ),
702 );
703 }
704
705 if (!observedDomain || observedDomain.zoneId !== zoneId) {
706 const created = yield* r2
707 .createBucketDomainCustom({
708 accountId,
709 bucketName,
710 jurisdiction,
711 domain: domain.name,
712 enabled: domain.enabled ?? true,
713 zoneId,
714 ciphers: domain.ciphers,
715 minTLS: domain.minTLS,
716 })
717 .pipe(
718 Effect.retry({
719 while: (e) => e._tag === "NoSuchBucket",
720 schedule: r2BucketEndpointConsistencySchedule,
721 }),
722 Effect.retry({
723 while: (e) => e._tag === "CustomDomainInUse",
724 schedule: r2CustomDomainConflictSchedule,
725 }),
726 );
727 return toCustomDomainAttributes({ ...created, zoneId });
728 }
729
730 const updated = yield* r2
731 .updateBucketDomainCustom({
732 accountId,
733 bucketName,
734 domain: domain.name,
735 jurisdiction,
736 enabled: domain.enabled ?? true,
737 ciphers: domain.ciphers,
738 minTLS: domain.minTLS,
739 })
740 .pipe(
741 Effect.retry({
742 while: (e) => e._tag === "NoSuchBucket",
743 schedule: r2BucketEndpointConsistencySchedule,
744 }),
745 );
746 return toCustomDomainAttributes({
747 ...updated,
748 enabled: updated.enabled ?? domain.enabled ?? true,
749 zoneId,
750 });
751 }),
752 { concurrency: "unbounded" },
753 );
754
755 return applied.sort((a, b) => a.domain.localeCompare(b.domain));
756 });
757
758 const listManagedDomain = Effect.fn(function* (
759 bucketName: string,
760 jurisdiction: Bucket.Jurisdiction,
761 options?: { retryMissing?: boolean },
762 ) {
763 const { accountId } = yield* yield* CloudflareEnvironment;
764 const fetch = r2.listBucketDomainManageds({
765 accountId,
766 bucketName,
767 jurisdiction,
768 });
769 return yield* (
770 options?.retryMissing === false
771 ? fetch
772 : fetch.pipe(
773 Effect.retry({
774 while: (e) => e._tag === "NoSuchBucket",
775 schedule: r2BucketEndpointConsistencySchedule,
776 }),
777 )
778 ).pipe(
779 // The hostname exists whether or not public access is on; a
780 // disabled domain serves 401, so only report it while enabled.
781 Effect.map((response) =>
782 response.enabled ? response.domain : undefined,
783 ),
784 Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
785 );
786 });
787
788 const reconcileManagedDomain = (
789 bucketName: string,
790 jurisdiction: Bucket.Jurisdiction,
791 desired: boolean,
792 ) =>
793 Effect.gen(function* () {
794 const { accountId } = yield* yield* CloudflareEnvironment;
795 const observed = yield* r2
796 .listBucketDomainManageds({
797 accountId,
798 bucketName,
799 jurisdiction,
800 })
801 .pipe(
802 Effect.retry({
803 while: (e) => e._tag === "NoSuchBucket",
804 schedule: r2BucketEndpointConsistencySchedule,
805 }),
806 );
807
808 if (observed.enabled === desired) {
809 return desired ? observed.domain : undefined;
810 }
811
812 const updated = yield* r2
813 .putBucketDomainManaged({
814 accountId,
815 bucketName,
816 enabled: desired,
817 jurisdiction,
818 })
819 .pipe(
820 Effect.retry({
821 while: (e) => e._tag === "NoSuchBucket",
822 schedule: r2BucketEndpointConsistencySchedule,
823 }),
824 );
825
826 return updated.enabled ? updated.domain : undefined;
827 });
828
829 // R2's `listBuckets` is not modelled as paginated by distilled and its
830 // response omits a continuation cursor, so paginate exhaustively with the
831 // `startAfter` query param: keep fetching full pages (capped at 1000)
832 // until a short page signals the end.
833 const listBucketsInJurisdiction = (
834 accountId: string,
835 jurisdiction: Bucket.Jurisdiction,
836 ) =>
837 Effect.gen(function* () {
838 const all: {
839 name: string;
840 jurisdiction: Bucket.Jurisdiction;
841 storageClass: Bucket.StorageClass;
842 location: Bucket.Location | undefined;
843 }[] = [];
844 let startAfter: string | undefined = undefined;
845 const perPage = 1000;
846 for (;;) {
847 const response: r2.ListBucketsResponse = yield* r2.listBuckets({
848 accountId,
849 jurisdiction,
850 perPage,
851 startAfter,
852 });
853 const raw = response.buckets ?? [];
854 const page = raw.filter(
855 (b): b is typeof b & { name: string } =>
856 typeof b.name === "string" && b.name !== "",
857 );
858 for (const b of page) {
859 all.push({
860 name: b.name,
861 jurisdiction: (b.jurisdiction ??
862 jurisdiction) as Bucket.Jurisdiction,
863 storageClass: (b.storageClass ??
864 "Standard") as Bucket.StorageClass,
865 location: normalizeLocation(b.location),
866 });
867 }
868 // Terminate on the RAW page length — the filtered length can be
869 // shorter on a full page (nameless entries), which would end the
870 // walk early and silently drop the remaining buckets.
871 if (raw.length < perPage || page.length === 0) break;
872 startAfter = page[page.length - 1].name;
873 }
874 return all;
875 });
876
877 const reconcileLifecycleRules = (
878 bucketName: string,
879 jurisdiction: Bucket.Jurisdiction,
880 desired: BucketLifecycleRule[],
881 ) =>
882 Effect.gen(function* () {
883 const { accountId } = yield* yield* CloudflareEnvironment;
884 const observed = yield* r2
885 .getBucketLifecycle({
886 accountId,
887 bucketName,
888 jurisdiction,
889 })
890 .pipe(
891 Effect.retry({
892 while: (e) => e._tag === "NoSuchBucket",
893 schedule: r2BucketEndpointConsistencySchedule,
894 }),
895 );
896
897 const observedRules = (observed.rules ?? []).map(toLifecycleRule);
898 const desiredRules = desired.map(normalizeLifecycleRule);
899
900 if (deepEqual(observedRules, desiredRules)) {
901 return desiredRules;
902 }
903
904 yield* r2
905 .putBucketLifecycle({
906 accountId,
907 bucketName,
908 jurisdiction,
909 rules: desired.map(toLifecyclePutPayload),
910 })
911 .pipe(
912 Effect.retry({
913 while: (e) => e._tag === "NoSuchBucket",
914 schedule: r2BucketEndpointConsistencySchedule,
915 }),
916 );
917
918 return desiredRules;
919 });
920
921 const reconcileCorsRules = (
922 bucketName: string,
923 jurisdiction: Bucket.Jurisdiction,
924 desired: BucketCorsRule[],
925 ) =>
926 Effect.gen(function* () {
927 const { accountId } = yield* yield* CloudflareEnvironment;
928 const observed = yield* r2
929 .getBucketCors({
930 accountId,
931 bucketName,
932 jurisdiction,
933 })
934 .pipe(
935 Effect.map((response) => (response.rules ?? []).map(toCorsRule)),
936 // A bucket with no CORS configuration is a typed error, not an
937 // empty rule list — normalize it to [] for the diff below.
938 Effect.catchTag("NoCorsConfiguration", () =>
939 Effect.succeed([] as Bucket.CorsRule[]),
940 ),
941 Effect.retry({
942 while: (e) => e._tag === "NoSuchBucket",
943 schedule: r2BucketEndpointConsistencySchedule,
944 }),
945 );
946
947 const desiredRules = desired.map(normalizeCorsRule);
948
949 if (deepEqual(observed, desiredRules)) {
950 return desiredRules;
951 }
952
953 if (desiredRules.length === 0) {
954 yield* r2
955 .deleteBucketCors({
956 accountId,
957 bucketName,
958 jurisdiction,
959 })
960 .pipe(
961 Effect.retry({
962 while: (e) => e._tag === "NoSuchBucket",
963 schedule: r2BucketEndpointConsistencySchedule,
964 }),
965 );
966 return desiredRules;
967 }
968
969 yield* r2
970 .putBucketCors({
971 accountId,
972 bucketName,
973 jurisdiction,
974 rules: desired.map(toCorsPutPayload),
975 })
976 .pipe(
977 Effect.retry({
978 while: (e) => e._tag === "NoSuchBucket",
979 schedule: r2BucketEndpointConsistencySchedule,
980 }),
981 );
982
983 return desiredRules;
984 });
985
986 return {
987 stables: ["bucketName", "accountId"],
988 list: () =>
989 Effect.gen(function* () {
990 const { accountId } = yield* yield* CloudflareEnvironment;
991 // R2 buckets are account-scoped but partitioned by jurisdiction, so
992 // enumerate each jurisdiction. Accounts not entitled to a given
993 // jurisdiction (e.g. `fedramp`) reject the route — treat as empty.
994 const jurisdictions: Bucket.Jurisdiction[] = [
995 "default",
996 "eu",
997 "fedramp",
998 ];
999 const perJurisdiction = yield* Effect.forEach(
1000 jurisdictions,
1001 (jurisdiction) =>
1002 listBucketsInJurisdiction(accountId, jurisdiction).pipe(
1003 // An account not entitled to a jurisdiction rejects the list
1004 // route with `Forbidden` ("Access Denied") or `InvalidRoute`
1005 // — there are simply no buckets there, so treat as empty.
1006 // @ts-expect-error
1007 Effect.catchTag(["InvalidRoute", "Forbidden"], () =>
1008 Effect.succeed([]),
1009 ),
1010 ),
1011 { concurrency: jurisdictions.length },
1012 );
1013 const buckets = perJurisdiction.flat();
1014
1015 // Hydrate each bucket into the exact `read` Attributes shape so the
1016 // result is directly usable by `delete` (which needs `domains` to
1017 // tear down custom domains).
1018 return yield* Effect.forEach(
1019 buckets,
1020 (bucket) =>
1021 Effect.gen(function* () {
1022 // The hydration reads are independent — issue them
1023 // concurrently so each bucket costs one round-trip of wall
1024 // clock instead of four (a large leaked-bucket census can
1025 // otherwise blow the nuke scan's per-provider timeout).
1026 const [domains, lifecycleRules, cors, publicDomain] =
1027 yield* Effect.all(
1028 [
1029 listCustomDomains(bucket.name, bucket.jurisdiction, {
1030 retryMissing: false,
1031 }).pipe(Effect.map((d) => d ?? [])),
1032 r2
1033 .getBucketLifecycle({
1034 accountId,
1035 bucketName: bucket.name,
1036 jurisdiction: bucket.jurisdiction,
1037 })
1038 .pipe(
1039 Effect.map((observed) =>
1040 (observed.rules ?? []).map(toLifecycleRule),
1041 ),
1042 Effect.catchTag("NoSuchBucket", () =>
1043 Effect.succeed([] as Bucket.LifecycleRule[]),
1044 ),
1045 ),
1046 r2
1047 .getBucketCors({
1048 accountId,
1049 bucketName: bucket.name,
1050 jurisdiction: bucket.jurisdiction,
1051 })
1052 .pipe(
1053 Effect.map((observed) =>
1054 (observed.rules ?? []).map(toCorsRule),
1055 ),
1056 Effect.catchTag(
1057 ["NoSuchBucket", "NoCorsConfiguration"],
1058 () => Effect.succeed([] as Bucket.CorsRule[]),
1059 ),
1060 ),
1061 listManagedDomain(bucket.name, bucket.jurisdiction, {
1062 retryMissing: false,
1063 }),
1064 ] as const,
1065 { concurrency: 4 },
1066 );
1067 return {
1068 bucketName: bucket.name,
1069 storageClass: bucket.storageClass,
1070 jurisdiction: bucket.jurisdiction,
1071 location: bucket.location,
1072 accountId,
1073 domains,
1074 lifecycleRules,
1075 cors,
1076 publicDomain,
1077 };
1078 }).pipe(
1079 // The custom-domain endpoint intermittently 500s ("Failed to
1080 // access or modify the bucket policy"), and some buckets 500
1081 // PERSISTENTLY. The blanket Cloudflare retry policy already
1082 // retries each call's transient blips; don't stack another
1083 // retry here (the budgets multiply into minutes per bucket).
1084 // A bucket that still 500s must not abort the account-wide
1085 // enumeration (nuke would then see ZERO buckets) — degrade
1086 // it to an un-hydrated shape; delete can still tear the
1087 // bucket down.
1088 Effect.catchTag("InternalServerError", () =>
1089 Effect.succeed({
1090 bucketName: bucket.name,
1091 storageClass: bucket.storageClass,
1092 jurisdiction: bucket.jurisdiction,
1093 location: bucket.location,
1094 accountId,
1095 domains: [] as Bucket.CustomDomain[],
1096 lifecycleRules: [] as Bucket.LifecycleRule[],
1097 cors: [] as Bucket.CorsRule[],
1098 publicDomain: undefined,
1099 }),
1100 ),
1101 ),
1102 { concurrency: 10 },
1103 );
1104 }),
1105 diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
1106 if (!isResolved(news)) return undefined;
1107 const { accountId } = yield* yield* CloudflareEnvironment;
1108 const oldName =
1109 output?.bucketName ?? (yield* createBucketName(id, olds.name));
1110 // Auto-generated names are engine-owned: the deployed name stays
1111 // authoritative even if the generator would name this id
1112 // differently today. Only an explicit user-provided name can
1113 // force a replace.
1114 const name = news.name ?? oldName;
1115 const oldJurisdiction =
1116 output?.jurisdiction ?? olds.jurisdiction ?? "default";
1117 const oldStorageClass =
1118 output?.storageClass ?? olds.storageClass ?? "Standard";
1119 if (
1120 (output?.accountId ?? accountId) !== accountId ||
1121 oldName !== name ||
1122 oldJurisdiction !== (news.jurisdiction ?? "default") ||
1123 olds.locationHint !== news.locationHint
1124 ) {
1125 return { action: "replace" } as const;
1126 }
1127 if (oldStorageClass !== (news.storageClass ?? "Standard")) {
1128 return {
1129 action: "update",
1130 // `accountId` is always stable across an update (a name/account
1131 // change is a `replace`); keep it now that `diff.stables`
1132 // overrides `provider.stables` rather than merging with it.
1133 stables:
1134 oldName === name ? ["bucketName", "accountId"] : ["accountId"],
1135 } as const;
1136 }
1137 if (!deepEqual(olds.domains, news.domains)) {
1138 return { action: "update" } as const;
1139 }
1140 if (!deepEqual(olds.lifecycleRules, news.lifecycleRules)) {
1141 return { action: "update" } as const;
1142 }
1143 if (!deepEqual(olds.cors, news.cors)) {
1144 return { action: "update" } as const;
1145 }
1146 if ((olds.publicAccess ?? false) !== (news.publicAccess ?? false)) {
1147 return { action: "update" } as const;
1148 }
1149 }),
1150 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
1151 const { accountId } = yield* yield* CloudflareEnvironment;
1152 // Prefer the deployed name: regenerating would target a different
1153 // bucket if the generator's output for this id ever drifts.
1154 const name =
1155 output?.bucketName ?? (yield* createBucketName(id, news.name));
1156 const acct = output?.accountId ?? accountId;
1157 const jurisdiction =
1158 output?.jurisdiction ?? news.jurisdiction ?? "default";
1159
1160 // Observe — fetch the bucket. R2 reports a deleted bucket as
1161 // `NoSuchBucket`; tolerate that so the reconciler falls
1162 // through to the create path.
1163 let observed = yield* r2
1164 .getBucket({
1165 accountId: acct,
1166 bucketName: name,
1167 jurisdiction,
1168 })
1169 .pipe(
1170 Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
1171 );
1172
1173 // Ensure — create if missing. R2 reports a concurrent create
1174 // (or partial state-persistence failure) as
1175 // `BucketAlreadyExists`; tolerate by re-fetching the bucket.
1176 if (!observed) {
1177 observed = yield* r2
1178 .createBucket({
1179 accountId: acct,
1180 name,
1181 storageClass: news.storageClass,
1182 jurisdiction: news.jurisdiction,
1183 locationHint: news.locationHint,
1184 })
1185 .pipe(
1186 Effect.catchTag("BucketAlreadyExists", () =>
1187 r2
1188 .getBucket({
1189 accountId: acct,
1190 bucketName: name,
1191 jurisdiction: news.jurisdiction,
1192 })
1193 .pipe(
1194 // The create lost a race, but the winning create may not
1195 // be readable yet — ride out the consistency lag.
1196 Effect.retry({
1197 while: (e) => e._tag === "NoSuchBucket",
1198 schedule: r2BucketEndpointConsistencySchedule,
1199 }),
1200 ),
1201 ),
1202 );
1203 }
1204
1205 // Sync — storage class is the only mutable bucket property
1206 // (location and jurisdiction are immutable; the diff flags those
1207 // as `replace`). PATCH carries the class in the
1208 // `cf-r2-storage-class` header. Address the bucket by the
1209 // RESOLVED name, never `observed.name` — a response missing the
1210 // name would collapse the URI to the collection path, which the
1211 // control plane rejects with `PATCH not supported for requested
1212 // URI`. Only patch when the desired class drifts from observed.
1213 const desiredStorageClass = news.storageClass ?? "Standard";
1214 const observedStorageClass = observed.storageClass ?? "Standard";
1215 if (observedStorageClass !== desiredStorageClass) {
1216 observed = yield* r2
1217 .patchBucket({
1218 accountId: acct,
1219 bucketName: name,
1220 storageClass: desiredStorageClass,
1221 jurisdiction: observed.jurisdiction ?? jurisdiction,
1222 })
1223 .pipe(
1224 // The patch endpoint can briefly 404 a freshly-created bucket
1225 // even after `getBucket` already sees it.
1226 Effect.retry({
1227 while: (e) => e._tag === "NoSuchBucket",
1228 schedule: r2BucketEndpointConsistencySchedule,
1229 }),
1230 );
1231 }
1232
1233 const attrs = {
1234 bucketName: observed.name ?? name,
1235 // Distilled widened generated string enums to open unions.
1236 storageClass: (observed.storageClass ??
1237 "Standard") as Bucket.StorageClass,
1238 jurisdiction: (observed.jurisdiction ??
1239 "default") as Bucket.Jurisdiction,
1240 location: normalizeLocation(observed.location),
1241 accountId: acct,
1242 };
1243
1244 const domains = yield* reconcileCustomDomains(
1245 attrs.bucketName,
1246 attrs.jurisdiction,
1247 news.domains ?? [],
1248 output?.domains ?? [],
1249 );
1250
1251 const lifecycleRules = yield* reconcileLifecycleRules(
1252 attrs.bucketName,
1253 attrs.jurisdiction,
1254 news.lifecycleRules ?? [],
1255 );
1256
1257 const cors = yield* reconcileCorsRules(
1258 attrs.bucketName,
1259 attrs.jurisdiction,
1260 news.cors ?? [],
1261 );
1262
1263 const publicDomain = yield* reconcileManagedDomain(
1264 attrs.bucketName,
1265 attrs.jurisdiction,
1266 news.publicAccess ?? false,
1267 );
1268
1269 return {
1270 ...attrs,
1271 domains,
1272 lifecycleRules,
1273 cors,
1274 publicDomain,
1275 };
1276 }),
1277 delete: Effect.fn(function* ({ olds = {}, output, force }) {
1278 yield* Effect.all(
1279 (output.domains ?? []).map((domain) =>
1280 r2
1281 .deleteBucketDomainCustom({
1282 accountId: output.accountId,
1283 bucketName: output.bucketName,
1284 domain: domain.domain,
1285 jurisdiction: output.jurisdiction,
1286 })
1287 .pipe(
1288 Effect.catchTag(
1289 ["DomainNotFound", "NoSuchBucket"],
1290 () => Effect.void,
1291 ),
1292 ),
1293 ),
1294 { concurrency: "unbounded" },
1295 );
1296
1297 // Whether we may destroy the bucket's CONTENTS.
1298 // - `olds.forceDestroy` — the user opted in on the resource props.
1299 // - `force` — set only by `alchemy unsafe nuke`, an explicitly
1300 // operator-confirmed account teardown. Nuke enumerates buckets
1301 // straight from the cloud (its `olds` is Attributes, not Props),
1302 // so `forceDestroy` is never present there.
1303 //
1304 // Without either, the objects are left alone and R2's own refusal
1305 // to delete a non-empty bucket (`BucketNotEmpty`) stands — that
1306 // refusal is the data protection users rely on, and emptying the
1307 // bucket to get past it is how a stack teardown turns into
1308 // irreversible data loss (#1248).
1309 const destroyContents = olds.forceDestroy === true || force === true;
1310 if (destroyContents) {
1311 yield* emptyBucket(output.bucketName, output.jurisdiction);
1312 }
1313 const deleteBucket = r2
1314 .deleteBucket({
1315 accountId: output.accountId,
1316 bucketName: output.bucketName,
1317 jurisdiction: output.jurisdiction,
1318 })
1319 .pipe(Effect.catchTag("NoSuchBucket", () => Effect.void));
1320 if (!destroyContents) {
1321 return yield* deleteBucket;
1322 }
1323 // A writer that outlives the emptying pass (a Durable Object's
1324 // alarm finishing a job while the stack tears down) can land an
1325 // object between the sweep and the delete: re-empty and retry,
1326 // bounded. Incomplete multipart uploads also hold a bucket; those
1327 // only a lifecycle rule clears, and the refusal then stands.
1328 yield* deleteBucket.pipe(
1329 Effect.catchTag("BucketNotEmpty", (error) =>
1330 Effect.sleep("2 seconds").pipe(
1331 Effect.andThen(
1332 emptyBucket(output.bucketName, output.jurisdiction),
1333 ),
1334 Effect.andThen(Effect.fail(error)),
1335 ),
1336 ),
1337 Effect.retry({
1338 while: (error) => error._tag === "BucketNotEmpty",
1339 times: 5,
1340 }),
1341 );
1342 }),
1343 read: Effect.fn(function* ({ id, output, olds }) {
1344 const { accountId } = yield* yield* CloudflareEnvironment;
1345 const name =
1346 output?.bucketName ?? (yield* createBucketName(id, olds?.name));
1347 const acct = output?.accountId ?? accountId;
1348 return yield* r2
1349 .getBucket({
1350 accountId: acct,
1351 bucketName: name,
1352 jurisdiction: output?.jurisdiction ?? olds?.jurisdiction,
1353 })
1354 .pipe(
1355 Effect.map((bucket) => ({
1356 bucketName: bucket.name!,
1357 // Distilled widened generated string enums to open unions.
1358 storageClass: (bucket.storageClass ??
1359 "Standard") as Bucket.StorageClass,
1360 jurisdiction: (bucket.jurisdiction ??
1361 "default") as Bucket.Jurisdiction,
1362 location: normalizeLocation(bucket.location),
1363 accountId: acct,
1364 domains: output?.domains ?? [],
1365 lifecycleRules: output?.lifecycleRules ?? [],
1366 cors: output?.cors ?? [],
1367 publicDomain: output?.publicDomain,
1368 })),
1369 Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
1370 );
1371 }),
1372 };
1373 }),
1374 );
1375
1376/**
1377 * Local (dev) provider — the bucket is purely virtual: a `dev:`-prefixed
1378 * bucket name keyed into the local workerd R2 simulator (data under
1379 * `.alchemy/local/r2`). `toRuntimeBinding` lowers an `r2_bucket` binding
1380 * whose bucket name is `dev:`-prefixed onto the local R2 service. R2 has no
1381 * opaque id — the name IS the identity — so the `dev:` marker rides on the
1382 * name (a `:` can never appear in a real R2 bucket name).
1383 *
1384 * Custom domains, lifecycle rules, CORS, and the managed r2.dev domain
1385 * are deploy-side concerns with no local behavior; the local attributes
1386 * report them empty.
1387 */
1388export const ProviderLocal = () =>
1389 Provider.succeed(Bucket, {
1390 stables: ["accountId"],
1391 diff: Effect.fn(function* ({ news = {}, output }) {
1392 const accountId = yield* localAccountId;
1393 if (!output?.bucketName) return { action: "update" } as const;
1394 if (!isResolved(news)) return undefined;
1395 if (output.accountId !== accountId) {
1396 return { action: "replace" } as const;
1397 }
1398 // Fall through to the engine's default prop diff.
1399 }),
1400 read: Effect.fn(function* ({ output }) {
1401 // Purely virtual — the persisted state row is the source of truth.
1402 return output ?? undefined;
1403 }),
1404 reconcile: Effect.fn(function* ({ news = {}, output }) {
1405 const accountId = yield* localAccountId;
1406 return {
1407 bucketName: output?.bucketName ?? generateLocalId(),
1408 storageClass: (news.storageClass ?? "Standard") as Bucket.StorageClass,
1409 jurisdiction: (news.jurisdiction ?? "default") as Bucket.Jurisdiction,
1410 location: undefined,
1411 accountId: output?.accountId ?? accountId,
1412 domains: [],
1413 lifecycleRules: [],
1414 cors: [],
1415 publicDomain: undefined,
1416 };
1417 }),
1418 delete: Effect.fn(function* () {
1419 // The simulator's on-disk data is keyed by the dev name; dropping the
1420 // state row is enough — orphaned blobs are reclaimed with `.alchemy`.
1421 }),
1422 });
1423
1424export const BucketProvider = () =>
1425 ProviderLayer.dual(Bucket, {
1426 local: () => ProviderLocal(),
1427 live: () => ProviderLive(),
1428 });
1429
1430// R2 can make a newly-created bucket visible to `getBucket` before its
1431// sub-resource endpoints (custom domains, lifecycle) accept it. Retry only
1432// that narrow `NoSuchBucket` lag here; not-found sub-resources are still
1433// treated as terminal for idempotent deletes.
1434const r2BucketEndpointConsistencySchedule = Schedule.max([
1435 Schedule.exponential(100),
1436 Schedule.recurs(5),
1437]);
1438
1439// R2 sub-resource reads (notably the custom-domain endpoint, which touches the
1440// bucket's public-access policy) can return a transient 500 ("Failed to access
1441// or modify the bucket policy"). Ride out the blip with a short bounded retry.
1442const r2TransientServerErrorSchedule = Schedule.max([
1443 Schedule.exponential("500 millis"),
1444 Schedule.recurs(6),
1445]);
1446
1447// Distilled widened generated string enums to open unions (`string & {}`); the
1448// API only ever returns the known variants, narrowed in `toCustomDomainAttributes`.
1449type CustomDomainResponse = {
1450 domain: string;
1451 zoneId?: string | null;
1452 enabled?: boolean | null;
1453 ciphers?: string[] | null;
1454 minTLS?: string | null;
1455 status?: { ownership: string; ssl: string } | null;
1456};
1457
1458const toCustomDomainAttributes = (
1459 domain: CustomDomainResponse,
1460): Bucket.CustomDomain => ({
1461 domain: domain.domain,
1462 zoneId: domain.zoneId ?? undefined,
1463 enabled: domain.enabled ?? true,
1464 ciphers: domain.ciphers ?? undefined,
1465 minTLS: (domain.minTLS ?? undefined) as Bucket.CustomDomain["minTLS"],
1466 status: (domain.status ?? undefined) as Bucket.CustomDomain["status"],
1467});
1468
1469const sameCustomDomainConfig = (
1470 observed: Bucket.CustomDomain | undefined,
1471 desired: BucketCustomDomain,
1472 zoneId: string,
1473): boolean =>
1474 observed !== undefined &&
1475 observed.zoneId === zoneId &&
1476 observed.enabled === (desired.enabled ?? true) &&
1477 deepEqual(observed.ciphers, desired.ciphers) &&
1478 observed.minTLS === desired.minTLS;
1479
1480type LifecycleRuleResponse = NonNullable<
1481 r2.GetBucketLifecycleResponse["rules"]
1482>[number];
1483
1484const toLifecycleRule = (
1485 rule: LifecycleRuleResponse,
1486): Bucket.LifecycleRule => ({
1487 id: rule.id,
1488 enabled: rule.enabled,
1489 prefix: rule.conditions.prefix ?? "",
1490 abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition
1491 ? { condition: rule.abortMultipartUploadsTransition.condition ?? undefined }
1492 : undefined,
1493 deleteObjectsTransition: rule.deleteObjectsTransition
1494 ? { condition: rule.deleteObjectsTransition.condition ?? undefined }
1495 : undefined,
1496 storageClassTransitions: rule.storageClassTransitions ?? undefined,
1497});
1498
1499const normalizeLifecycleRule = (
1500 rule: BucketLifecycleRule,
1501): Bucket.LifecycleRule => ({
1502 id: rule.id,
1503 enabled: rule.enabled ?? true,
1504 prefix: rule.prefix ?? "",
1505 abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition
1506 ? { condition: rule.abortMultipartUploadsTransition.condition }
1507 : undefined,
1508 deleteObjectsTransition: rule.deleteObjectsTransition
1509 ? { condition: rule.deleteObjectsTransition.condition }
1510 : undefined,
1511 storageClassTransitions: rule.storageClassTransitions,
1512});
1513
1514type CorsRuleResponse = NonNullable<r2.GetBucketCorsResponse["rules"]>[number];
1515
1516const toCorsRule = (rule: CorsRuleResponse): Bucket.CorsRule => ({
1517 id: rule.id ?? undefined,
1518 // Distilled widened generated string enums to open unions.
1519 allowedMethods: rule.allowed.methods as Bucket.CorsRule["allowedMethods"],
1520 allowedOrigins: rule.allowed.origins,
1521 allowedHeaders: rule.allowed.headers ?? undefined,
1522 exposeHeaders: rule.exposeHeaders ?? undefined,
1523 maxAgeSeconds: rule.maxAgeSeconds ?? undefined,
1524});
1525
1526const normalizeCorsRule = (rule: BucketCorsRule): Bucket.CorsRule => ({
1527 id: rule.id,
1528 allowedMethods: rule.allowedMethods,
1529 allowedOrigins: rule.allowedOrigins,
1530 allowedHeaders: rule.allowedHeaders,
1531 exposeHeaders: rule.exposeHeaders,
1532 maxAgeSeconds: rule.maxAgeSeconds,
1533});
1534
1535const toCorsPutPayload = (
1536 rule: BucketCorsRule,
1537): NonNullable<r2.PutBucketCorsRequest["rules"]>[number] => ({
1538 id: rule.id,
1539 allowed: {
1540 methods: rule.allowedMethods,
1541 origins: rule.allowedOrigins,
1542 headers: rule.allowedHeaders,
1543 },
1544 exposeHeaders: rule.exposeHeaders,
1545 maxAgeSeconds: rule.maxAgeSeconds,
1546});
1547
1548const toLifecyclePutPayload = (
1549 rule: BucketLifecycleRule,
1550): NonNullable<r2.PutBucketLifecycleRequest["rules"]>[number] => ({
1551 id: rule.id,
1552 enabled: rule.enabled ?? true,
1553 conditions: { prefix: rule.prefix ?? "" },
1554 abortMultipartUploadsTransition: rule.abortMultipartUploadsTransition,
1555 deleteObjectsTransition: rule.deleteObjectsTransition,
1556 storageClassTransitions: rule.storageClassTransitions,
1557});
1558
1559// Cloudflare keys a custom domain to a single bucket at the zone level. After a
1560// domain is deleted, re-attaching the same hostname can transiently 409 with
1561// "Domain already in use" (typed as `CustomDomainInUse`) until the prior
1562// association is fully released. Treat that narrow conflict as eventual
1563// consistency and retry it on create. Releasing a custom domain after delete
1564// can lag a few seconds, so give the conflict a longer, bounded budget than
1565// the bucket-endpoint lag above.
1566const r2CustomDomainConflictSchedule = Schedule.max([
1567 Schedule.spaced("2 seconds"),
1568 Schedule.recurs(8),
1569]);