1import { Credentials } from "@distilled.cloud/cloudflare/Credentials";
2import * as zones from "@distilled.cloud/cloudflare/zones";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import type * as HttpClient from "effect/http/HttpClient";
6
7/**
8 * Reference to an existing Cloudflare Zone. Accepts:
9 * - a zone id (32 hex characters),
10 * - a zone name (`example.com`), or
11 * - a `{ zoneId, name? }` object (e.g. the output of a `Zone` resource or
12 * {@link importZone}).
13 */
14export type Reference = string | { zoneId: string; name?: string };
15
16export const isId = (zone: string): boolean => /^[a-f0-9]{32}$/i.test(zone);
17
18export const matchesZoneHostname = (
19 zoneName: string,
20 hostname: string,
21): boolean => hostname === zoneName || hostname.endsWith(`.${zoneName}`);
22
23export const resolveZoneId = ({
24 accountId,
25 zone,
26 hostname,
27}: {
28 accountId: string;
29 zone: Reference | undefined;
30 hostname: string;
31}) =>
32 Effect.gen(function* () {
33 if (typeof zone === "object") return zone.zoneId;
34 if (typeof zone === "string" && isId(zone)) return zone;
35
36 const lookup = zone ?? hostname;
37 for (const candidate of zoneNameCandidates(lookup)) {
38 const match = yield* findZoneByName({ accountId, name: candidate });
39 if (match) return match.id;
40 }
41 return yield* Effect.fail(
42 new Error(`Cloudflare zone not found for ${lookup}`),
43 );
44 });
45
46type ZoneListItem = {
47 id: string;
48 name: string;
49 account: { id?: string | null };
50};
51
52export const findZoneByName = ({
53 accountId,
54 name,
55}: {
56 accountId: string;
57 name: string;
58}): Effect.Effect<
59 ZoneListItem | undefined,
60 zones.ListZonesError,
61 Credentials | HttpClient.HttpClient
62> =>
63 Effect.gen(function* () {
64 // Distilled `listZones` rides `Retry.makeDefault` (5xx / throttling).
65 // The previous raw `fetch` failed the first time Cloudflare answered
66 // `{ success: false, errors: [{ message: "unhandled server error" }] }`.
67 const page = yield* zones.listZones({
68 account: { id: accountId },
69 name,
70 perPage: 1,
71 });
72 const match = (page.result ?? []).find(
73 (candidate) =>
74 candidate.name === name && candidate.account.id === accountId,
75 );
76 if (match === undefined) return undefined;
77 return {
78 id: match.id,
79 name: match.name,
80 account: { id: match.account.id },
81 };
82 });
83
84/**
85 * Exhaustively enumerate every zone in an account. Used by `list()` lifecycle
86 * operations on zone-scoped resources to fan out across all zones. Returns only
87 * the stable `{ id, name }` pair each caller needs to drive a per-zone list.
88 */
89export const listAllZones = (
90 accountId: string,
91): Effect.Effect<
92 { id: string; name: string }[],
93 zones.ListZonesError,
94 Credentials | HttpClient.HttpClient
95> =>
96 zones.listZones.pages({ account: { id: accountId } }).pipe(
97 Stream.runCollect,
98 Effect.map((chunk) =>
99 Array.from(chunk).flatMap((page) =>
100 (page.result ?? []).map((zone) => ({ id: zone.id, name: zone.name })),
101 ),
102 ),
103 );
104
105/** Hostname plus each parent label, longest first — used to infer a zone. */
106export const zoneNameCandidates = (hostname: string): string[] => {
107 const parts = hostname.split(".");
108 return parts.slice(0, -1).map((_, index) => parts.slice(index).join("."));
109};