| 1 | import * as pages from "@distilled.cloud/cloudflare/pages"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.Pages.Domain" as const; |
| 13 | type 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 | */ |
| 20 | export 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 | |
| 31 | export 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 | |
| 47 | export 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 | |
| 97 | export 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 | */ |
| 142 | export const Domain = Resource<Domain>(TypeId); |
| 143 | |
| 144 | /** |
| 145 | * Returns true if the given value is a Domain resource. |
| 146 | */ |
| 147 | export const isDomain = (value: unknown): value is Domain => |
| 148 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 149 | |
| 150 | export 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 | |
| 309 | type 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 | */ |
| 319 | const 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 | |
| 329 | const 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 | }); |