Skip to content

feat(cloudflare/r2): add bucket custom domains#241

Open
agcty wants to merge 4 commits intoalchemy-run:mainfrom
agcty:codex/cloudflare-r2-custom-domains
Open

feat(cloudflare/r2): add bucket custom domains#241
agcty wants to merge 4 commits intoalchemy-run:mainfrom
agcty:codex/cloudflare-r2-custom-domains

Conversation

@agcty
Copy link
Copy Markdown

@agcty agcty commented May 5, 2026

Adds R2 bucket custom-domain support to the existing Cloudflare R2Bucket resource.

  • accepts domains as a single custom domain or an array
  • supports { zoneId }, zone ID strings, and hostname strings for zone selection
  • creates, updates, and removes only domains tracked by the bucket resource state
  • recreates a custom domain when its zone changes because Cloudflare does not expose zoneId on the update endpoint
  • adds a focused live test gated by CLOUDFLARE_TEST_R2_DOMAIN_ZONE_ID and CLOUDFLARE_TEST_R2_DOMAIN

Verification run:

  • bun vitest run packages/alchemy/test/Cloudflare/R2/CustomDomain.test.ts
  • bun tsc -b --pretty false
  • git diff --check

@agcty
Copy link
Copy Markdown
Author

agcty commented May 5, 2026

Verification update:

  • Focused test passed without live env: bun vitest run packages/alchemy/test/Cloudflare/R2/CustomDomain.test.ts (skipped as expected).
  • Typecheck passed: bun tsc -b --pretty false.
  • Live test passed against real Cloudflare resources under sneef.xyz: created an R2 bucket, attached a generated disposable custom domain, updated the custom domain, destroyed the stack, and verified cleanup. Cloudflare returns NoSuchBucket after bucket deletion when checking the custom domain, which the test now accepts as successful cleanup.

@agcty agcty marked this pull request as ready for review May 5, 2026 13:56
Copy link
Copy Markdown
Contributor

@john-royal john-royal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some minor changes here. Once you make those, should be good to go.

Comment on lines +503 to +524
Effect.flatMap((bucket) =>
Effect.gen(function* () {
const jurisdiction = bucket.jurisdiction ?? "default";
const existingDomains =
output?.domains && output.domains.length > 0
? yield* listCustomDomains(bucket.name!, jurisdiction)
: [];
const ownedDomains = new Set(
output?.domains.map((domain) => domain.domain) ?? [],
);
return {
bucketName: bucket.name!,
storageClass: bucket.storageClass ?? "Standard",
jurisdiction,
location: normalizeLocation(bucket.location),
accountId: acct,
domains: existingDomains.filter((domain) =>
ownedDomains.has(domain.domain),
),
};
}),
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably remove this bit entirely since we don't actually read this result.

Comment on lines +291 to +305
for (const previousDomain of previous) {
if (!desiredDomains.has(previousDomain.domain)) {
yield* deleteBucketDomainCustom({
accountId,
bucketName,
domain: previousDomain.domain,
jurisdiction,
}).pipe(
Effect.catchIf(
isMissingCustomDomainOrBucket,
() => Effect.void,
),
);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do this in parallel instead of sequentially using Effect.forEach with concurrency: "unbounded"?

Comment on lines +307 to +313
const applied: R2Bucket.CustomDomain[] = [];
for (const domain of desired) {
const zoneId = yield* resolveZoneId(domain.zone);
const observedDomain = observedByDomain.get(domain.domain);
if (!sameCustomDomainConfig(observedDomain, domain, zoneId)) {
if (observedDomain) {
if (observedDomain.zoneId !== zoneId) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here. I'd change to const applied = yield* Effect.forEach(..., { concurrency: "unbounded" }) so these run in parallel instead of sequentially.

Comment on lines +360 to +368
const refreshed = yield* r2
.getBucketDomainCustom({
accountId,
bucketName,
domain: domain.domain,
jurisdiction,
})
.pipe(Effect.map(toCustomDomainAttributes));
applied.push(refreshed);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the createBucketDomainCustom and updateBucketDomainCustom functions return the attributes we're looking for? If yes, we can optimize by relying on those results instead of making another API call here.

Comment on lines +167 to +208
const isZoneIdString = (zone: string): boolean => /^[a-f0-9]{32}$/i.test(zone);

const matchesHostname = (zoneName: string, hostname: string): boolean =>
hostname === zoneName || hostname.endsWith(`.${zoneName}`);

const normalizeDomains = (
domains: R2BucketProps["domains"],
): R2BucketCustomDomain[] =>
domains === undefined ? [] : Array.isArray(domains) ? domains : [domains];

const toCustomDomainAttributes = (
domain:
| r2.GetBucketDomainCustomResponse
| r2.ListBucketDomainCustomsResponse["domains"][number],
): R2Bucket.CustomDomain => ({
domain: domain.domain,
zoneId: domain.zoneId ?? undefined,
enabled: domain.enabled,
ciphers: domain.ciphers ?? undefined,
minTLS: domain.minTLS ?? undefined,
status: "status" in domain ? domain.status : undefined,
});

const sameCustomDomainConfig = (
observed: R2Bucket.CustomDomain | undefined,
desired: R2BucketCustomDomain,
zoneId: string,
): boolean =>
observed !== undefined &&
observed.zoneId === zoneId &&
observed.enabled === (desired.enabled ?? true) &&
deepEqual(observed.ciphers, desired.ciphers) &&
observed.minTLS === desired.minTLS;

const isMissingCustomDomainOrBucket = (error: unknown): boolean =>
typeof error === "object" &&
error !== null &&
(("status" in error && (error as { status: unknown }).status === 404) ||
("_tag" in error &&
((error as { _tag: unknown })._tag === "DomainNotFound" ||
(error as { _tag: unknown })._tag === "NoSuchBucket")));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please move these to the bottom of the file (helps with being able to read the file from top to bottom)

@agcty agcty force-pushed the codex/cloudflare-r2-custom-domains branch from 3d31327 to a02ef07 Compare May 6, 2026 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants