From c6d275819e155681630836dd1f9601e518081a5c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 May 2026 13:20:19 +0200 Subject: [PATCH 1/3] feat(cloudflare/zone): add Zone resource --- packages/alchemy/src/Cloudflare/Providers.ts | 3 + packages/alchemy/src/Cloudflare/Zone/Zone.ts | 235 ++++++++++++++++++ packages/alchemy/src/Cloudflare/Zone/index.ts | 1 + packages/alchemy/src/Cloudflare/index.ts | 1 + .../alchemy/test/Cloudflare/Zone/Zone.test.ts | 48 ++++ 5 files changed, 288 insertions(+) create mode 100644 packages/alchemy/src/Cloudflare/Zone/Zone.ts create mode 100644 packages/alchemy/src/Cloudflare/Zone/index.ts create mode 100644 packages/alchemy/test/Cloudflare/Zone/Zone.test.ts diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 4a5b9298a..d8403b13a 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -32,6 +32,7 @@ import * as Tunnel from "./Tunnel/index.ts"; import * as VpcService from "./VpcService/index.ts"; import * as Workers from "./Workers/index.ts"; import * as Workflows from "./Workers/Workflow.ts"; +import * as Zone from "./Zone/index.ts"; export { Credentials } from "@distilled.cloud/cloudflare/Credentials"; @@ -84,6 +85,7 @@ export const providers = () => Workers.FetchPolicy, Workers.Worker, Workflows.WorkflowResource, + Zone.Zone, ]), ).pipe( Layer.provide( @@ -122,6 +124,7 @@ export const providers = () => Workers.FetchPolicyLive, Workers.WorkerProvider(), Workflows.WorkflowProvider(), + Zone.ZoneProvider(), ), ), Layer.provideMerge( diff --git a/packages/alchemy/src/Cloudflare/Zone/Zone.ts b/packages/alchemy/src/Cloudflare/Zone/Zone.ts new file mode 100644 index 000000000..76dc1b0cc --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Zone/Zone.ts @@ -0,0 +1,235 @@ +import * as zones from "@distilled.cloud/cloudflare/zones"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { deepEqual, isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Providers } from "../Providers.ts"; + +export type ZoneType = "full" | "partial" | "secondary" | "internal"; + +export type ZoneProps = { + /** + * Domain name for the zone. + */ + name: string; + /** + * Zone setup type. + * @default "full" + */ + type?: ZoneType; + /** + * Whether the zone is paused. + */ + paused?: boolean; + /** + * Vanity name servers for the zone. Only available on supported Cloudflare + * plans. + */ + vanityNameServers?: string[]; + /** + * Whether the zone should be deleted when the resource is destroyed. + * + * Set to `false` when representing an existing production zone that should + * outlive the stack. + * @default true + */ + delete?: boolean; +}; + +export type Zone = Resource< + "Cloudflare.Zone", + ZoneProps, + { + zoneId: string; + accountId: string; + name: string; + type: ZoneType | undefined; + status: "initializing" | "pending" | "active" | "moved" | undefined; + paused: boolean | undefined; + nameServers: string[]; + originalNameServers: string[] | null; + createdOn: string; + modifiedOn: string; + activatedOn: string | null; + vanityNameServers: string[] | undefined; + }, + never, + Providers +>; + +export const isZone = (value: unknown): value is Zone => + typeof value === "object" && + value !== null && + "Type" in value && + (value as Zone).Type === "Cloudflare.Zone"; + +/** + * A Cloudflare DNS zone. + * + * Zones represent domains managed by Cloudflare. They can be used directly by + * zone-scoped resources such as Rulesets and R2 custom domains. + * + * @section Existing Zone + * @example Reference a production zone without deleting it on destroy + * ```typescript + * const zone = yield* Cloudflare.Zone("Zone", { + * name: "example.com", + * type: "full", + * delete: false, + * }); + * ``` + * + * @section Creating a Zone + * @example Full zone + * ```typescript + * const zone = yield* Cloudflare.Zone("Zone", { + * name: "example.com", + * }); + * ``` + */ +export const Zone = Resource("Cloudflare.Zone")({}); + +type ZoneResponse = + | zones.GetZoneResponse + | zones.CreateZoneResponse + | zones.PatchZoneResponse; + +const toZoneAttributes = ( + zone: ZoneResponse | zones.ListZonesResponse["result"][number], + accountId: string, +): Zone["Attributes"] => ({ + zoneId: zone.id, + accountId: zone.account.id ?? accountId, + name: zone.name, + type: zone.type ?? undefined, + status: zone.status ?? undefined, + paused: zone.paused ?? undefined, + nameServers: zone.nameServers, + originalNameServers: zone.originalNameServers, + createdOn: zone.createdOn, + modifiedOn: zone.modifiedOn, + activatedOn: zone.activatedOn, + vanityNameServers: zone.vanityNameServers ?? undefined, +}); + +const mutablePropsChanged = ( + attrs: Zone["Attributes"], + props: ZoneProps, +): boolean => + attrs.type !== (props.type ?? "full") || + attrs.paused !== (props.paused ?? false) || + !deepEqual(attrs.vanityNameServers, props.vanityNameServers); + +const isNotFoundError = (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 === "NotFound")); + +export const ZoneProvider = () => + Provider.effect( + Zone, + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + const getZone = yield* zones.getZone; + const createZone = yield* zones.createZone; + const patchZone = yield* zones.patchZone; + const deleteZone = yield* zones.deleteZone; + + const findZoneByName = (name: string) => + zones.listZones.items({}).pipe( + Stream.filter( + (zone) => zone.name === name && zone.account.id === accountId, + ), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); + + const readById = (zoneId: string) => + getZone({ zoneId }).pipe( + Effect.map((zone) => toZoneAttributes(zone, accountId)), + Effect.catchIf(isNotFoundError, () => Effect.succeed(undefined)), + ); + + return { + stables: ["zoneId", "accountId", "name"], + diff: Effect.fn(function* ({ olds, news, output }) { + if (!isResolved(news)) return undefined; + if ((output?.accountId ?? accountId) !== accountId) { + return { action: "replace" } as const; + } + if (output?.name && output.name !== news.name) { + return { action: "replace" } as const; + } + if (olds.name && olds.name !== news.name) { + return { action: "replace" } as const; + } + if (output && mutablePropsChanged(output, news)) { + return { action: "update" } as const; + } + if ( + olds.type !== news.type || + olds.paused !== news.paused || + !deepEqual(olds.vanityNameServers, news.vanityNameServers) + ) { + return { action: "update" } as const; + } + }), + reconcile: Effect.fn(function* ({ news, output }) { + let observed = output?.zoneId + ? yield* readById(output.zoneId) + : undefined; + + if (!observed) { + const existing = yield* findZoneByName(news.name); + observed = existing + ? toZoneAttributes(existing, accountId) + : undefined; + } + + if (!observed) { + observed = toZoneAttributes( + yield* createZone({ + account: { id: accountId }, + name: news.name, + type: news.type ?? "full", + }), + accountId, + ); + } + + if (mutablePropsChanged(observed, news)) { + observed = toZoneAttributes( + yield* patchZone({ + zoneId: observed.zoneId, + type: news.type, + paused: news.paused, + vanityNameServers: news.vanityNameServers, + }), + accountId, + ); + } + + return observed; + }), + delete: Effect.fn(function* ({ olds, output }) { + if (olds.delete === false) return; + yield* deleteZone({ zoneId: output.zoneId }).pipe( + Effect.catchIf(isNotFoundError, () => Effect.void), + ); + }), + read: Effect.fn(function* ({ output, olds }) { + if (output?.zoneId) { + const observed = yield* readById(output.zoneId); + if (observed) return observed; + } + + const existing = yield* findZoneByName(olds.name); + return existing ? toZoneAttributes(existing, accountId) : undefined; + }), + }; + }), + ); diff --git a/packages/alchemy/src/Cloudflare/Zone/index.ts b/packages/alchemy/src/Cloudflare/Zone/index.ts new file mode 100644 index 000000000..2b1e80238 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Zone/index.ts @@ -0,0 +1 @@ +export * from "./Zone.ts"; diff --git a/packages/alchemy/src/Cloudflare/index.ts b/packages/alchemy/src/Cloudflare/index.ts index d8c2ab277..0ec45490e 100644 --- a/packages/alchemy/src/Cloudflare/index.ts +++ b/packages/alchemy/src/Cloudflare/index.ts @@ -19,3 +19,4 @@ export * from "./Tunnel/index.ts"; export * from "./VpcService/index.ts"; export * from "./Website/index.ts"; export * from "./Workers/index.ts"; +export * from "./Zone/index.ts"; diff --git a/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts new file mode 100644 index 000000000..37e81f077 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts @@ -0,0 +1,48 @@ +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Test from "@/Test/Vitest"; +import * as zones from "@distilled.cloud/cloudflare/zones"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const zoneName = process.env.CLOUDFLARE_TEST_ZONE_NAME; + +test.provider.skipIf(!zoneName)( + "adopts existing zone and respects delete false", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const zone = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Zone("TestZone", { + name: zoneName!, + type: "full", + delete: false, + }); + }), + ); + + expect(zone.name).toEqual(zoneName); + expect(zone.accountId).toEqual(accountId); + expect(zone.zoneId).toBeTruthy(); + + const actual = yield* zones.getZone({ zoneId: zone.zoneId }); + expect(actual.name).toEqual(zoneName); + + yield* stack.destroy(); + + const retained = yield* zones.getZone({ zoneId: zone.zoneId }); + expect(retained.name).toEqual(zoneName); + }).pipe(logLevel), +); From 5d8a665bdeadf1a793db3dbc6303b12b81eb5756 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 May 2026 15:49:00 +0200 Subject: [PATCH 2/3] test(cloudflare/zone): preserve live zone settings --- .../alchemy/test/Cloudflare/Zone/Zone.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts index 37e81f077..ac8d4f294 100644 --- a/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts +++ b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts @@ -4,7 +4,9 @@ import * as Test from "@/Test/Vitest"; import * as zones from "@distilled.cloud/cloudflare/zones"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import { MinimumLogLevel } from "effect/References"; +import * as Stream from "effect/Stream"; const { test } = Test.make({ providers: Cloudflare.providers() }); @@ -20,6 +22,7 @@ test.provider.skipIf(!zoneName)( (stack) => Effect.gen(function* () { const { accountId } = yield* CloudflareEnvironment; + const existing = yield* findZoneByName(zoneName!, accountId); yield* stack.destroy(); @@ -27,7 +30,9 @@ test.provider.skipIf(!zoneName)( Effect.gen(function* () { return yield* Cloudflare.Zone("TestZone", { name: zoneName!, - type: "full", + type: existing.type ?? undefined, + paused: existing.paused ?? undefined, + vanityNameServers: existing.vanityNameServers ?? undefined, delete: false, }); }), @@ -46,3 +51,19 @@ test.provider.skipIf(!zoneName)( expect(retained.name).toEqual(zoneName); }).pipe(logLevel), ); + +const findZoneByName = Effect.fn(function* ( + name: string, + accountId: string, +) { + return yield* zones.listZones.items({}).pipe( + Stream.filter((zone) => zone.name === name && zone.account.id === accountId), + Stream.runHead, + Effect.map(Option.getOrUndefined), + Effect.flatMap((zone) => + zone + ? Effect.succeed(zone) + : Effect.fail(new Error(`Cloudflare test zone not found: ${name}`)), + ), + ); +}); From c2e268b51a6f5992f0b9f29dd503de799573b086 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 18 May 2026 20:18:13 +0200 Subject: [PATCH 3/3] refactor(cloudflare/zone): use shared zone lookup --- packages/alchemy/src/Cloudflare/Zone.ts | 2 +- packages/alchemy/src/Cloudflare/Zone/Zone.ts | 17 ++++------------- .../alchemy/test/Cloudflare/Zone/Zone.test.ts | 13 +++++-------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/alchemy/src/Cloudflare/Zone.ts b/packages/alchemy/src/Cloudflare/Zone.ts index 47763ff44..ee6e1d92f 100644 --- a/packages/alchemy/src/Cloudflare/Zone.ts +++ b/packages/alchemy/src/Cloudflare/Zone.ts @@ -42,7 +42,7 @@ type ZoneListResponse = { result?: { id: string; name: string; account: { id?: string | null } }[]; }; -const findZoneByName = ({ +export const findZoneByName = ({ accountId, name, }: { diff --git a/packages/alchemy/src/Cloudflare/Zone/Zone.ts b/packages/alchemy/src/Cloudflare/Zone/Zone.ts index 76dc1b0cc..12ec34e9e 100644 --- a/packages/alchemy/src/Cloudflare/Zone/Zone.ts +++ b/packages/alchemy/src/Cloudflare/Zone/Zone.ts @@ -1,12 +1,11 @@ import * as zones from "@distilled.cloud/cloudflare/zones"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Stream from "effect/Stream"; import { deepEqual, isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Providers } from "../Providers.ts"; +import { findZoneByName as findCloudflareZoneByName } from "../Zone.ts"; export type ZoneType = "full" | "partial" | "secondary" | "internal"; @@ -140,13 +139,7 @@ export const ZoneProvider = () => const deleteZone = yield* zones.deleteZone; const findZoneByName = (name: string) => - zones.listZones.items({}).pipe( - Stream.filter( - (zone) => zone.name === name && zone.account.id === accountId, - ), - Stream.runHead, - Effect.map(Option.getOrUndefined), - ); + findCloudflareZoneByName({ accountId, name }); const readById = (zoneId: string) => getZone({ zoneId }).pipe( @@ -185,9 +178,7 @@ export const ZoneProvider = () => if (!observed) { const existing = yield* findZoneByName(news.name); - observed = existing - ? toZoneAttributes(existing, accountId) - : undefined; + observed = existing ? yield* readById(existing.id) : undefined; } if (!observed) { @@ -228,7 +219,7 @@ export const ZoneProvider = () => } const existing = yield* findZoneByName(olds.name); - return existing ? toZoneAttributes(existing, accountId) : undefined; + return existing ? yield* readById(existing.id) : undefined; }), }; }), diff --git a/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts index ac8d4f294..8656de9d9 100644 --- a/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts +++ b/packages/alchemy/test/Cloudflare/Zone/Zone.test.ts @@ -1,12 +1,11 @@ import * as Cloudflare from "@/Cloudflare"; import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import { findZoneByName as findCloudflareZoneByName } from "@/Cloudflare/Zone.ts"; import * as Test from "@/Test/Vitest"; import * as zones from "@distilled.cloud/cloudflare/zones"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { MinimumLogLevel } from "effect/References"; -import * as Stream from "effect/Stream"; const { test } = Test.make({ providers: Cloudflare.providers() }); @@ -22,7 +21,7 @@ test.provider.skipIf(!zoneName)( (stack) => Effect.gen(function* () { const { accountId } = yield* CloudflareEnvironment; - const existing = yield* findZoneByName(zoneName!, accountId); + const existing = yield* findTestZoneByName(zoneName!, accountId); yield* stack.destroy(); @@ -52,18 +51,16 @@ test.provider.skipIf(!zoneName)( }).pipe(logLevel), ); -const findZoneByName = Effect.fn(function* ( +const findTestZoneByName = Effect.fn(function* ( name: string, accountId: string, ) { - return yield* zones.listZones.items({}).pipe( - Stream.filter((zone) => zone.name === name && zone.account.id === accountId), - Stream.runHead, - Effect.map(Option.getOrUndefined), + const match = yield* findCloudflareZoneByName({ accountId, name }).pipe( Effect.flatMap((zone) => zone ? Effect.succeed(zone) : Effect.fail(new Error(`Cloudflare test zone not found: ${name}`)), ), ); + return yield* zones.getZone({ zoneId: match.id }); });