1import * as addressing from "@distilled.cloud/cloudflare/addressing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.RegionalHostname.RegionalHostname" as const;
14type TypeId = typeof TypeId;
15
16export interface Props {
17 /**
18 * The zone the regional hostname belongs to. Changing it forces a
19 * replacement.
20 */
21 zoneId: string;
22 /**
23 * DNS hostname to be regionalized. Must be a subdomain of the zone;
24 * wildcards are supported for one level (e.g. `*.example.com`). The
25 * hostname is the API path identifier — changing it forces a replacement.
26 */
27 hostname: string;
28 /**
29 * Identifying key for the region (e.g. `"eu"`, `"us"` — discoverable via
30 * `addressing.listRegionalHostnameRegions`). Mutable — patched in place.
31 */
32 regionKey: string;
33 /**
34 * Which routing method to use for the regional hostname. Create-only —
35 * the PATCH endpoint only accepts `regionKey`, so changing it forces a
36 * replacement.
37 */
38 routing?: string;
39}
40
41export interface Attributes {
42 /** The zone the regional hostname belongs to. */
43 zoneId: string;
44 /** The regionalized DNS hostname. */
45 hostname: string;
46 /** Identifying key for the region. */
47 regionKey: string;
48 /** Routing method used for the regional hostname, if set. */
49 routing: string | undefined;
50 /** When the regional hostname was created. */
51 createdOn: string;
52}
53
54export type RegionalHostname = Resource<
55 TypeId,
56 Props,
57 Attributes,
58 never,
59 Providers
60>;
61
62/**
63 * A Regional Hostname restricts which Cloudflare data centers decrypt and
64 * service HTTPS traffic for a hostname (Data Localization Suite / Regional
65 * Services).
66 *
67 * A DNS record for the hostname must exist in the zone for regionalization
68 * to take effect (soft dependency on `Cloudflare.DNS.Record`). Only
69 * `regionKey` is mutable; `hostname` is the path identifier and `routing`
70 * is create-only, so both force a replacement.
71 *
72 * Requires the Data Localization Suite (or Enterprise) entitlement on the
73 * zone.
74 * ### Regionalizing a Hostname
75 * **Example:** Pin a hostname to the EU
76 * ```typescript
77 * const regional = yield* Cloudflare.RegionalHostname.RegionalHostname("eu-only", {
78 * zoneId: zone.zoneId,
79 * hostname: "app.example.com",
80 * regionKey: "eu",
81 * });
82 * ```
83 *
84 * **Example:** Move it to the US in place
85 * ```typescript
86 * const regional = yield* Cloudflare.RegionalHostname.RegionalHostname("eu-only", {
87 * zoneId: zone.zoneId,
88 * hostname: "app.example.com",
89 * regionKey: "us",
90 * });
91 * ```
92 *
93 * @see https://developers.cloudflare.com/data-localization/regional-services/
94 *
95 * @resource
96 * @product Regional Hostnames
97 * @category Domains & DNS
98 */
99export const RegionalHostname = Resource<RegionalHostname>(TypeId, {
100 aliases: ["Cloudflare.RegionalHostname"],
101});
102
103/**
104 * Returns true if the given value is a RegionalHostname resource.
105 */
106export const isRegionalHostname = (value: unknown): value is RegionalHostname =>
107 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
108
109export const RegionalHostnameProvider = () =>
110 Provider.succeed(RegionalHostname, {
111 stables: ["zoneId", "hostname", "createdOn"],
112
113 list: Effect.fn(function* () {
114 const { accountId } = yield* yield* CloudflareEnvironment;
115 // Regional hostnames are zone-scoped (/zones/{id}/addressing/
116 // regional_hostnames) with no account-wide enumeration API — fan out
117 // over every zone and list each zone's regional hostnames.
118 const zones = yield* listAllZones(accountId);
119 const rows = yield* Effect.forEach(
120 zones,
121 (zone) =>
122 addressing.listRegionalHostnames.pages({ zoneId: zone.id }).pipe(
123 Stream.runCollect,
124 Effect.map((chunk) =>
125 Array.from(chunk).flatMap((page) =>
126 (page.result ?? []).map((item): Attributes => ({
127 zoneId: zone.id,
128 hostname: item.hostname,
129 regionKey: item.regionKey,
130 routing: item.routing ?? undefined,
131 createdOn: item.createdOn,
132 })),
133 ),
134 ),
135 // Plan-gated zones (no Data Localization Suite entitlement)
136 // reject the route, and zones the ambient token cannot access
137 // return a 403 `Forbidden`; skip both rather than failing the
138 // whole list.
139 Effect.catchTag(["InvalidRoute", "Forbidden"], () =>
140 Effect.succeed([]),
141 ),
142 ),
143 { concurrency: 10 },
144 );
145 return rows.flat();
146 }),
147
148 diff: Effect.fn(function* ({ olds, news, output }) {
149 if (olds === undefined) return undefined;
150 if (!isResolved(news) || !isResolved(olds)) return undefined;
151 // zoneId is Input<string>; by diff time both sides are concrete.
152 const oldZoneId = output?.zoneId ?? olds.zoneId;
153 if (
154 typeof oldZoneId === "string" &&
155 typeof news.zoneId === "string" &&
156 news.zoneId !== oldZoneId
157 ) {
158 return { action: "replace" } as const;
159 }
160 // The hostname is the API path identifier.
161 if (news.hostname !== (output?.hostname ?? olds.hostname)) {
162 return { action: "replace" } as const;
163 }
164 // PATCH only accepts regionKey — routing is create-only.
165 if (
166 olds.routing !== undefined &&
167 news.routing !== undefined &&
168 news.routing !== olds.routing
169 ) {
170 return { action: "replace" } as const;
171 }
172 return undefined;
173 }),
174
175 read: Effect.fn(function* ({ output, olds }) {
176 // The hostname is the identifier — cold reads are trivial.
177 const zoneId =
178 output?.zoneId ??
179 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
180 const hostname = output?.hostname ?? olds?.hostname;
181 if (!zoneId || typeof hostname !== "string") return undefined;
182 const observed = yield* getHostname(zoneId, hostname);
183 return observed ? toAttributes(observed, zoneId) : undefined;
184 }),
185
186 reconcile: Effect.fn(function* ({ news }) {
187 // Inputs have been resolved to concrete strings by Plan.
188 const zoneId = news.zoneId as string;
189
190 // 1. Observe — the hostname itself is the identifier.
191 const observed = yield* getHostname(zoneId, news.hostname);
192
193 // 2. Ensure — create if missing.
194 if (!observed) {
195 const created = yield* addressing.createRegionalHostname({
196 zoneId,
197 hostname: news.hostname,
198 regionKey: news.regionKey,
199 routing: news.routing,
200 });
201 return toAttributes(created, zoneId);
202 }
203
204 // 3. Sync — regionKey is the only patchable field; skip the PATCH
205 // entirely on a no-op.
206 if (observed.regionKey !== news.regionKey) {
207 const patched = yield* addressing.patchRegionalHostname({
208 zoneId,
209 hostname: news.hostname,
210 regionKey: news.regionKey,
211 });
212 return toAttributes(patched, zoneId);
213 }
214
215 return toAttributes(observed, zoneId);
216 }),
217
218 delete: Effect.fn(function* ({ output }) {
219 yield* addressing
220 .deleteRegionalHostname({
221 zoneId: output.zoneId,
222 hostname: output.hostname,
223 })
224 .pipe(
225 Effect.catchTag(
226 ["RegionalHostnameNotFound", "RegionalHostnameEmpty"],
227 () => Effect.void,
228 ),
229 );
230 }),
231 });
232
233/**
234 * Read a regional hostname, mapping "gone" (`RegionalHostnameNotFound` —
235 * code 1002 — and `RegionalHostnameEmpty` — code 1000 `not_found`, returned
236 * when the zone has no regional hostnames at all) to `undefined`.
237 */
238const getHostname = (zoneId: string, hostname: string) =>
239 addressing
240 .getRegionalHostname({ zoneId, hostname })
241 .pipe(
242 Effect.catchTag(
243 ["RegionalHostnameNotFound", "RegionalHostnameEmpty"],
244 () => Effect.succeed(undefined),
245 ),
246 );
247
248const toAttributes = (
249 hostname: addressing.GetRegionalHostnameResponse,
250 zoneId: string,
251): Attributes => ({
252 zoneId,
253 hostname: hostname.hostname,
254 regionKey: hostname.regionKey,
255 routing: hostname.routing ?? undefined,
256 createdOn: hostname.createdOn,
257});