Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/alchemy/src/Cloudflare/Providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -84,6 +85,7 @@ export const providers = () =>
Workers.FetchPolicy,
Workers.Worker,
Workflows.WorkflowResource,
Zone.Zone,
]),
).pipe(
Layer.provide(
Expand Down Expand Up @@ -122,6 +124,7 @@ export const providers = () =>
Workers.FetchPolicyLive,
Workers.WorkerProvider(),
Workflows.WorkflowProvider(),
Zone.ZoneProvider(),
),
),
Layer.provideMerge(
Expand Down
2 changes: 1 addition & 1 deletion packages/alchemy/src/Cloudflare/Zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type ZoneListResponse = {
result?: { id: string; name: string; account: { id?: string | null } }[];
};

const findZoneByName = ({
export const findZoneByName = ({
accountId,
name,
}: {
Expand Down
226 changes: 226 additions & 0 deletions packages/alchemy/src/Cloudflare/Zone/Zone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import * as zones from "@distilled.cloud/cloudflare/zones";
import * as Effect from "effect/Effect";
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";

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<Zone>("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) =>
findCloudflareZoneByName({ accountId, name });

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 ? yield* readById(existing.id) : 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 ? yield* readById(existing.id) : undefined;
}),
};
}),
);
1 change: 1 addition & 0 deletions packages/alchemy/src/Cloudflare/Zone/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Zone.ts";
1 change: 1 addition & 0 deletions packages/alchemy/src/Cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
66 changes: 66 additions & 0 deletions packages/alchemy/test/Cloudflare/Zone/Zone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 { 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;
const existing = yield* findTestZoneByName(zoneName!, accountId);

yield* stack.destroy();

const zone = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Zone("TestZone", {
name: zoneName!,
type: existing.type ?? undefined,
paused: existing.paused ?? undefined,
vanityNameServers: existing.vanityNameServers ?? undefined,
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),
);

const findTestZoneByName = Effect.fn(function* (
name: string,
accountId: string,
) {
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 });
});
Loading