1import * as customHostnames from "@distilled.cloud/cloudflare/custom-hostnames";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12export interface FallbackOriginProps {
13 /**
14 * Zone the fallback origin belongs to. Stable — a zone has exactly one
15 * fallback origin, so changing the zone triggers replacement.
16 */
17 zoneId: string;
18 /**
19 * Your origin hostname that requests to custom hostnames are sent to.
20 * Must be a DNS record (A, AAAA or CNAME) within the zone — create the
21 * `Cloudflare.DNS.Record` first and pass its name.
22 *
23 * Mutable — the API is a PUT-style upsert.
24 */
25 origin: string;
26}
27
28export interface FallbackOriginAttributes {
29 /** Zone that owns this fallback origin. */
30 zoneId: string;
31 /** The configured origin hostname. */
32 origin: string;
33 /**
34 * Activation status (`initializing`, `pending_deployment`, `active`,
35 * …). Deployment is asynchronous (typically minutes) and is not
36 * blocked on.
37 */
38 status: string | undefined;
39}
40
41export type FallbackOrigin = Resource<
42 "Cloudflare.CustomHostname.FallbackOrigin",
43 FallbackOriginProps,
44 FallbackOriginAttributes,
45 never,
46 Providers
47>;
48
49/**
50 * The Cloudflare for SaaS fallback origin of a zone.
51 *
52 * A zone-level singleton: requests to any of the zone's custom hostnames
53 * that don't have a `customOriginServer` are routed to this origin.
54 * Setting a fallback origin implicitly enables Cloudflare for SaaS on
55 * the zone.
56 *
57 * Safety: when there is no prior state, `read` reports an existing
58 * fallback origin as `Unowned`, so the engine refuses to overwrite an
59 * out-of-band configuration unless `--adopt` (or `adopt(true)`) is set.
60 * ### Setting the Fallback Origin
61 * **Example:** Point custom hostname traffic at your origin
62 * ```typescript
63 * const record = yield* Cloudflare.DNS.Record("Origin", {
64 * zoneId: zone.zoneId,
65 * name: "origin.my-saas.com",
66 * type: "A",
67 * content: "203.0.113.1",
68 * proxied: true,
69 * });
70 * const fallback = yield* Cloudflare.CustomHostname.FallbackOrigin("Fallback", {
71 * zoneId: zone.zoneId,
72 * origin: record.name,
73 * });
74 * ```
75 *
76 * @resource
77 * @product Custom Hostnames
78 * @category Domains & DNS
79 */
80export const FallbackOrigin = Resource<FallbackOrigin>(
81 "Cloudflare.CustomHostname.FallbackOrigin",
82 { aliases: ["Cloudflare.FallbackOrigin"] },
83);
84
85export const isFallbackOrigin = (value: unknown): value is FallbackOrigin =>
86 Predicate.hasProperty(value, "Type") &&
87 value.Type === "Cloudflare.CustomHostname.FallbackOrigin";
88
89export const FallbackOriginProvider = () =>
90 Provider.succeed(FallbackOrigin, {
91 stables: ["zoneId"],
92
93 list: Effect.fn(function* () {
94 const { accountId } = yield* yield* CloudflareEnvironment;
95 // Zone singleton — no account-wide list. Enumerate every zone and
96 // read its fallback origin; zones without one configured (or
97 // without Cloudflare for SaaS access) are skipped.
98 const allZones = yield* listAllZones(accountId);
99 const rows = yield* Effect.forEach(
100 allZones.map((zone) => zone.id),
101 (zoneId) =>
102 observeFallbackOrigin(zoneId).pipe(
103 Effect.map((observed): FallbackOriginAttributes | undefined => {
104 if (
105 observed?.origin === undefined ||
106 observed.status === "pending_deletion" ||
107 observed.status === "deletion_timed_out"
108 ) {
109 return undefined;
110 }
111 return {
112 zoneId,
113 origin: observed.origin,
114 status: observed.status,
115 };
116 }),
117 // Zones without Cloudflare for SaaS entitlement reject the
118 // route; skip them rather than failing the whole enumeration.
119 Effect.catchTag(["SaasAccessNotGranted", "Forbidden"], () =>
120 Effect.succeed(undefined),
121 ),
122 ),
123 { concurrency: 10 },
124 );
125 return rows.filter(
126 (row): row is FallbackOriginAttributes => row !== undefined,
127 );
128 }),
129
130 diff: Effect.fn(function* ({ olds = {}, news }) {
131 const o = olds as FallbackOriginProps;
132 const n = news as FallbackOriginProps;
133 // zoneId is Input<string>; compare only once both sides are
134 // concrete strings.
135 if (
136 typeof o.zoneId === "string" &&
137 typeof n.zoneId === "string" &&
138 o.zoneId !== n.zoneId
139 ) {
140 return { action: "replace" } as const;
141 }
142 }),
143
144 reconcile: Effect.fn(function* ({ news }) {
145 // Inputs have been resolved to concrete strings by Plan.
146 const zoneId = news.zoneId as string;
147 const origin = news.origin as string;
148
149 // 1. Observe the singleton.
150 const observed = yield* observeFallbackOrigin(zoneId);
151
152 // 2/3. Ensure + sync — PUT is a true upsert; skip the API call
153 // entirely when the observed origin already matches (the
154 // status field converges asynchronously on its own).
155 if (
156 observed?.origin === origin &&
157 observed.status !== "pending_deletion" &&
158 observed.status !== "deletion_timed_out"
159 ) {
160 return { zoneId, origin: observed.origin, status: observed.status };
161 }
162 const updated = yield* customHostnames.putFallbackOrigin({
163 zoneId,
164 origin,
165 });
166
167 // 4. Return fresh attributes.
168 return {
169 zoneId,
170 origin: updated.origin ?? origin,
171 status: updated.status ?? undefined,
172 };
173 }),
174
175 delete: Effect.fn(function* ({ output }) {
176 yield* customHostnames
177 .deleteFallbackOrigin({ zoneId: output.zoneId })
178 .pipe(Effect.catchTag("FallbackOriginNotFound", () => Effect.void));
179 }),
180
181 read: Effect.fn(function* ({ output, olds }) {
182 const zoneId =
183 output?.zoneId ??
184 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
185 if (!zoneId) return undefined;
186 const observed = yield* observeFallbackOrigin(zoneId);
187 if (
188 observed?.origin === undefined ||
189 observed.status === "pending_deletion" ||
190 observed.status === "deletion_timed_out"
191 ) {
192 return undefined;
193 }
194 const attrs: FallbackOriginAttributes = {
195 zoneId,
196 origin: observed.origin,
197 status: observed.status,
198 };
199 // Owned path: we have persisted state — refresh it. Cold path: a
200 // fallback origin exists but we cannot prove we created it (the
201 // API has no ownership markers), so gate takeover behind adoption.
202 return output?.zoneId ? attrs : Unowned(attrs);
203 }),
204 });
205
206interface ObservedFallbackOrigin {
207 readonly origin: string | undefined;
208 readonly status: string | undefined;
209}
210
211// An unset fallback origin is reported by the API as a bare 404 —
212// typed in the distilled union as `FallbackOriginNotFound`.
213const observeFallbackOrigin = (zoneId: string) =>
214 customHostnames.getFallbackOrigin({ zoneId }).pipe(
215 Effect.map((r): ObservedFallbackOrigin => ({
216 origin: r.origin ?? undefined,
217 status: r.status ?? undefined,
218 })),
219 Effect.catchTag("FallbackOriginNotFound", () => Effect.succeed(undefined)),
220 );