1import * as pages from "@distilled.cloud/cloudflare/pages";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Pages.Domain" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Lifecycle status of a Pages custom domain. Newly attached domains start
17 * `initializing`/`pending` and become `active` once DNS validation and
18 * certificate issuance complete.
19 */
20export type DomainStatus =
21 | "initializing"
22 | "pending"
23 | "active"
24 | "deactivated"
25 | "blocked"
26 | "error"
27 // Keep the union open so new Cloudflare statuses aren't blocked by
28 // stale types.
29 | (string & {});
30
31export interface DomainProps {
32 /**
33 * Name of the Pages project the domain is attached to (e.g.
34 * `project.name`). The attachment cannot be moved — changing the project
35 * triggers a replacement.
36 */
37 projectName: string;
38 /**
39 * The custom domain name (e.g. `www.example.com`). The domain is the
40 * attachment's identity — changing it triggers a replacement.
41 *
42 * Declared as plain `string` so it is statically knowable inside `diff`.
43 */
44 name: string;
45}
46
47export interface DomainAttributes {
48 /**
49 * Cloudflare-assigned UUID of the domain attachment.
50 */
51 domainId: string;
52 /**
53 * The Cloudflare account the project belongs to.
54 */
55 accountId: string;
56 /**
57 * Name of the Pages project the domain is attached to.
58 */
59 projectName: string;
60 /**
61 * The custom domain name.
62 */
63 name: string;
64 /**
65 * Current lifecycle status of the domain. Newly attached domains stay
66 * `pending` until DNS validation completes (the zone needs a CNAME from
67 * the domain to the project's `*.pages.dev` subdomain).
68 */
69 status: DomainStatus;
70 /**
71 * Certificate authority issuing the domain's TLS certificate.
72 */
73 certificateAuthority: string;
74 /**
75 * Status of the domain-ownership validation.
76 */
77 validationStatus: string;
78 /**
79 * Method used for domain-ownership validation (`http` or `txt`).
80 */
81 validationMethod: string;
82 /**
83 * Status of the domain verification.
84 */
85 verificationStatus: string;
86 /**
87 * Zone tag (zone id) of the Cloudflare zone the domain belongs to, when
88 * the zone is on the same account.
89 */
90 zoneTag: string;
91 /**
92 * When the domain was attached to the project.
93 */
94 createdOn: string;
95}
96
97export type Domain = Resource<
98 TypeId,
99 DomainProps,
100 DomainAttributes,
101 never,
102 Providers
103>;
104
105/**
106 * A custom domain attached to a Cloudflare Pages project.
107 *
108 * Attaching a domain starts Cloudflare's validation flow: the domain must
109 * resolve to the project (typically via a CNAME record pointing at the
110 * project's `*.pages.dev` subdomain) before its status becomes `active`.
111 * The resource does not wait for activation — compose it with
112 * `Cloudflare.DNS.Record` to create the CNAME, and certificate issuance
113 * completes asynchronously.
114 *
115 * Both properties are the attachment's identity, so every change triggers a
116 * replacement (detach + attach).
117 * ### Attaching a Domain
118 * **Example:** Custom domain with its CNAME record
119 * ```typescript
120 * const project = yield* Cloudflare.Pages.Project("site", {});
121 *
122 * const domain = yield* Cloudflare.Pages.Domain("site-domain", {
123 * projectName: project.name,
124 * name: "www.example.com",
125 * });
126 *
127 * yield* Cloudflare.DNS.Record("site-cname", {
128 * zoneId: zone.zoneId,
129 * name: "www.example.com",
130 * type: "CNAME",
131 * content: project.subdomain,
132 * proxied: true,
133 * });
134 * ```
135 *
136 * @see https://developers.cloudflare.com/pages/configuration/custom-domains/
137 *
138 * @resource
139 * @product Pages
140 * @category Workers & Compute
141 */
142export const Domain = Resource<Domain>(TypeId);
143
144/**
145 * Returns true if the given value is a Domain resource.
146 */
147export const isDomain = (value: unknown): value is Domain =>
148 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
149
150export const DomainProvider = () =>
151 Provider.succeed(Domain, {
152 stables: [
153 "domainId",
154 "accountId",
155 "projectName",
156 "name",
157 "zoneTag",
158 "createdOn",
159 ],
160 diff: Effect.fn(function* ({ olds, news, output }) {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 const o = olds as DomainProps | undefined;
163 const n = news as DomainProps;
164 if ((output?.accountId ?? accountId) !== accountId) {
165 return { action: "replace" } as const;
166 }
167 // The domain name is the attachment's identity — no rename API.
168 const oldName = output?.name ?? o?.name;
169 if (oldName !== undefined && oldName !== n.name) {
170 return { action: "replace" } as const;
171 }
172 // projectName is Input<string>; compare only once both sides are
173 // concrete strings.
174 const oldProject =
175 output?.projectName ??
176 (typeof o?.projectName === "string" ? o.projectName : undefined);
177 if (
178 oldProject !== undefined &&
179 typeof n.projectName === "string" &&
180 oldProject !== n.projectName
181 ) {
182 return { action: "replace" } as const;
183 }
184 return undefined;
185 }),
186 // Parent fan-out: domains are sub-resources of a Pages project, and
187 // there is no account-wide domain enumeration API. Enumerate every
188 // Pages project (account-scoped, paginated), then list each project's
189 // domains with bounded concurrency and flatten into the `read`
190 // Attributes shape.
191 list: Effect.fn(function* () {
192 const { accountId } = yield* yield* CloudflareEnvironment;
193
194 const projectNames = yield* pages.listProjects.pages({ accountId }).pipe(
195 Stream.runCollect,
196 Effect.map((chunk) =>
197 Array.from(chunk).flatMap((page) =>
198 (page.result ?? []).map((project) => project.name),
199 ),
200 ),
201 );
202
203 const perProject = yield* Effect.forEach(
204 projectNames,
205 (projectName) =>
206 pages.listProjectDomains.pages({ accountId, projectName }).pipe(
207 Stream.runCollect,
208 Effect.map((chunk) =>
209 Array.from(chunk).flatMap((page) =>
210 (page.result ?? []).map((domain) =>
211 toAttributes(domain, accountId, projectName),
212 ),
213 ),
214 ),
215 // The project can vanish between enumeration and the
216 // per-project list — skip it rather than failing the whole
217 // enumeration.
218 Effect.catchTag("ProjectNotFound", () => Effect.succeed([])),
219 ),
220 { concurrency: 10 },
221 );
222
223 return perProject.flat();
224 }),
225 read: Effect.fn(function* ({ output, olds }) {
226 const { accountId } = yield* yield* CloudflareEnvironment;
227 const acct = output?.accountId ?? accountId;
228 const projectName =
229 output?.projectName ??
230 (typeof olds?.projectName === "string" ? olds.projectName : undefined);
231 const name = output?.name ?? olds?.name;
232 if (projectName === undefined || name === undefined) return undefined;
233
234 const observed = yield* getDomain(acct, projectName, name);
235 if (!observed) return undefined;
236 const attrs = toAttributes(observed, acct, projectName);
237 // With persisted state the attachment is ours; on a cold read we
238 // cannot prove ownership (domain attachments carry no markers), so
239 // gate takeover behind the adopt policy.
240 return output?.domainId ? attrs : Unowned(attrs);
241 }),
242 reconcile: Effect.fn(function* ({ news }) {
243 const { accountId } = yield* yield* CloudflareEnvironment;
244 // Inputs have been resolved to concrete strings by Plan.
245 const projectName = news.projectName as string;
246
247 // 1. Observe — `(projectName, name)` is the attachment's identity.
248 let observed = yield* getDomain(accountId, projectName, news.name);
249
250 // 2. Ensure — existence-only resource: attach when missing,
251 // tolerating the AlreadyExists race by re-reading.
252 if (!observed) {
253 observed = yield* pages
254 .createProjectDomain({
255 accountId,
256 projectName,
257 name: news.name,
258 })
259 .pipe(
260 Effect.catchTag("PagesDomainAlreadyExists", (originalError) =>
261 Effect.gen(function* () {
262 const existing = yield* getDomain(
263 accountId,
264 projectName,
265 news.name,
266 );
267 if (!existing) return yield* Effect.fail(originalError);
268 return existing;
269 }),
270 ),
271 );
272 } else if (observed.status === "pending" || observed.status === "error") {
273 // 3. Sync — nothing is mutable on the attachment itself, but a
274 // stalled validation can be re-kicked. Tolerate the domain
275 // vanishing between observe and patch.
276 observed = yield* pages
277 .patchProjectDomain({
278 accountId,
279 projectName,
280 domainName: news.name,
281 })
282 .pipe(
283 Effect.catchTag("PagesDomainNotFound", () =>
284 Effect.succeed(observed!),
285 ),
286 );
287 }
288
289 // 4. Return fresh attributes.
290 return toAttributes(observed, accountId, projectName);
291 }),
292 delete: Effect.fn(function* ({ output }) {
293 // Idempotent: the domain may already be detached, or the whole
294 // project may already be gone (deleting a project detaches its
295 // domains) — both count as success.
296 yield* pages
297 .deleteProjectDomain({
298 accountId: output.accountId,
299 projectName: output.projectName,
300 domainName: output.name,
301 })
302 .pipe(
303 Effect.catchTag("PagesDomainNotFound", () => Effect.void),
304 Effect.catchTag("ProjectNotFound", () => Effect.void),
305 );
306 }),
307 });
308
309type ObservedDomain =
310 | pages.GetProjectDomainResponse
311 | pages.CreateProjectDomainResponse
312 | pages.PatchProjectDomainResponse;
313
314/**
315 * Read a domain attachment, mapping "gone" to `undefined` — either the
316 * domain is not attached (`PagesDomainNotFound`, code 8000021) or the
317 * whole project no longer exists (`ProjectNotFound`, code 8000007).
318 */
319const getDomain = (
320 accountId: string,
321 projectName: string,
322 domainName: string,
323) =>
324 pages.getProjectDomain({ accountId, projectName, domainName }).pipe(
325 Effect.catchTag("PagesDomainNotFound", () => Effect.succeed(undefined)),
326 Effect.catchTag("ProjectNotFound", () => Effect.succeed(undefined)),
327 );
328
329const toAttributes = (
330 domain: ObservedDomain,
331 accountId: string,
332 projectName: string,
333): DomainAttributes => ({
334 domainId: domain.domainId,
335 accountId,
336 projectName,
337 name: domain.name,
338 status: domain.status,
339 // While a domain is still `initializing`/`pending`, Cloudflare omits the
340 // certificate authority, validation/verification blocks and (for an
341 // off-account zone) the zone tag entirely — coalesce to "" so the
342 // Attributes shape stays stable across the async activation lifecycle.
343 certificateAuthority: domain.certificateAuthority ?? "",
344 validationStatus: domain.validationData?.status ?? "",
345 validationMethod: domain.validationData?.method ?? "",
346 verificationStatus: domain.verificationData?.status ?? "",
347 zoneTag: domain.zoneTag ?? "",
348 createdOn: domain.createdOn,
349});