1import * as cache from "@distilled.cloud/cloudflare/cache";
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 { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.Cache.OriginCloudRegion" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * The cloud vendor hosting an origin. Region identifiers are
19 * vendor-specific — the valid set for each vendor is returned by the
20 * `supported_regions` endpoint
21 * (`/zones/{zone_id}/cache/origin_cloud_regions/supported_regions`).
22 */
23export type OriginCloudRegionVendor = "aws" | "azure" | "gcp" | "oci";
24
25export interface OriginCloudRegionProps {
26 /**
27 * Zone the mapping belongs to. Stable — moving the mapping to a
28 * different zone triggers a replacement.
29 */
30 zoneId: string;
31 /**
32 * Origin IP address (IPv4 or IPv6) the mapping applies to. The IP is the
33 * mapping's identity within the zone, so changing it triggers a
34 * replacement.
35 *
36 * Cloudflare normalizes the IP to canonical form before storage
37 * (RFC 5952 for IPv6) — supply IPv6 addresses in canonical form so the
38 * stored identity matches the prop.
39 */
40 ip: string;
41 /**
42 * Cloud vendor hosting the origin. Mutable — patched in place.
43 */
44 vendor: OriginCloudRegionVendor;
45 /**
46 * Cloud vendor region identifier, e.g. `us-east-1` for `aws`. Must be a
47 * valid region for the specified vendor (see the vendor's entry in the
48 * `supported_regions` endpoint). Mutable — patched in place.
49 */
50 region: string;
51}
52
53export interface OriginCloudRegionAttributes {
54 /** Zone the mapping belongs to. */
55 zoneId: string;
56 /** Canonicalized origin IP the mapping applies to. */
57 originIp: string;
58 /** Cloud vendor hosting the origin. */
59 vendor: string;
60 /** Cloud vendor region identifier. */
61 region: string;
62 /** When the mapping was last modified, if Cloudflare reports it. */
63 modifiedOn: string | undefined;
64}
65
66export type OriginCloudRegion = Resource<
67 TypeId,
68 OriginCloudRegionProps,
69 OriginCloudRegionAttributes,
70 never,
71 Providers
72>;
73
74/**
75 * An origin cloud-region mapping of a Cloudflare zone
76 * (`/zones/{zone_id}/cache/origin_cloud_regions`).
77 *
78 * The mapping tells Cloudflare which public-cloud vendor region hosts a
79 * given origin IP, letting Tiered Cache pick an upper-tier data center
80 * close to the origin for better cache-fill performance.
81 *
82 * A mapping's identity is its origin IP within the zone — changing `ip`
83 * (or `zoneId`) triggers a replacement, while `vendor` and `region` are
84 * patched in place. Mappings carry no ownership markers: when there is no
85 * prior state, `read` reports an existing mapping for the same IP as
86 * `Unowned`, so the engine refuses to take it over unless `--adopt`
87 * (or `adopt(true)`) is set.
88 * ### Mapping origins to cloud regions
89 * **Example:** Map an origin IP to an AWS region
90 * ```typescript
91 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
92 *
93 * yield* Cloudflare.Cache.OriginCloudRegion("ApiOrigin", {
94 * zoneId: zone.zoneId,
95 * ip: "192.0.2.10",
96 * vendor: "aws",
97 * region: "us-east-1",
98 * });
99 * ```
100 *
101 * **Example:** Map several origins of the same zone
102 * ```typescript
103 * // One resource per origin IP — the IP is the mapping's identity.
104 * yield* Cloudflare.Cache.OriginCloudRegion("UsOrigin", {
105 * zoneId: zone.zoneId,
106 * ip: "192.0.2.10",
107 * vendor: "gcp",
108 * region: "us-central1",
109 * });
110 * yield* Cloudflare.Cache.OriginCloudRegion("EuOrigin", {
111 * zoneId: zone.zoneId,
112 * ip: "192.0.2.20",
113 * vendor: "gcp",
114 * region: "europe-west1",
115 * });
116 * ```
117 *
118 * @see https://developers.cloudflare.com/cache/how-to/tiered-cache/
119 *
120 * @resource
121 * @product Cache
122 * @category Performance & Reliability
123 */
124export const OriginCloudRegion = Resource<OriginCloudRegion>(TypeId);
125
126/**
127 * Returns true if the given value is an OriginCloudRegion resource.
128 */
129export const isOriginCloudRegion = (
130 value: unknown,
131): value is OriginCloudRegion =>
132 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
133
134/**
135 * Compare two origin IPs for identity. Cloudflare canonicalizes stored IPs
136 * (lowercased hex groups for IPv6 per RFC 5952), so a case-insensitive
137 * compare covers the common canonical-vs-uppercase drift without trying to
138 * re-implement full RFC 5952 normalization.
139 */
140const sameIp = (a: string, b: string): boolean =>
141 a.trim().toLowerCase() === b.trim().toLowerCase();
142
143export const OriginCloudRegionProvider = () =>
144 Provider.succeed(OriginCloudRegion, {
145 stables: ["zoneId", "originIp"],
146
147 list: Effect.fn(function* () {
148 const { accountId } = yield* yield* CloudflareEnvironment;
149 // Mappings live inside a zone (`/zones/{zone_id}/origin/cloud_regions`)
150 // with no account-wide list — enumerate every zone, then list its
151 // mappings and flatten. A zone may have zero mappings.
152 const allZones = yield* listAllZones(accountId);
153 const rows = yield* Effect.forEach(
154 allZones.map((zone) => zone.id),
155 (zoneId) =>
156 cache.listOriginCloudRegions.pages({ zoneId }).pipe(
157 Stream.runCollect,
158 Effect.map((chunk) =>
159 Array.from(chunk).flatMap((page) =>
160 (page.result ?? []).map((mapping) =>
161 toAttributes(zoneId, mapping),
162 ),
163 ),
164 ),
165 // Plan-gated / deleted zones reject the route; skip them.
166 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
167 ),
168 { concurrency: 10 },
169 );
170 return rows.flat();
171 }),
172
173 diff: Effect.fn(function* ({ olds, news, output }) {
174 if (!isResolved(news)) return undefined;
175 // The IP is the mapping's identity — changing it replaces. Prefer the
176 // canonicalized IP cached on output; fall back to resolved old props.
177 const oldIp =
178 output?.originIp ??
179 (olds !== undefined && isResolved(olds) ? olds.ip : undefined);
180 if (oldIp !== undefined && !sameIp(oldIp, news.ip)) {
181 return { action: "replace" } as const;
182 }
183 const oldZoneId =
184 output?.zoneId ??
185 (olds !== undefined && isResolved(olds) ? olds.zoneId : undefined);
186 if (oldZoneId !== undefined && oldZoneId !== news.zoneId) {
187 return { action: "replace" } as const;
188 }
189 return undefined;
190 }),
191
192 read: Effect.fn(function* ({ output, olds }) {
193 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
194 const ip = output?.originIp ?? olds?.ip;
195 if (!zoneId || !ip) return undefined;
196
197 const observed = yield* getMapping(zoneId, ip);
198 if (observed === undefined) return undefined;
199
200 // Owned path: we have persisted attributes for this mapping.
201 if (output?.originIp) return observed;
202
203 // Adoption path: a mapping for this IP already exists but carries no
204 // ownership markers — brand it `Unowned` so the engine refuses to
205 // take over unless `adopt` is set.
206 return Unowned(observed);
207 }),
208
209 reconcile: Effect.fn(function* ({ news, output }) {
210 // Inputs have been resolved to concrete strings by Plan.
211 const zoneId = news.zoneId as string;
212 // `output` caches the canonicalized IP; fall back to the prop.
213 const ip = output?.originIp ?? news.ip;
214
215 // 1. Observe — cloud state is authoritative; a cached output is only
216 // a hint and the mapping may be gone.
217 const observed = yield* getMapping(zoneId, ip);
218
219 // 2. Ensure + sync in one step — the endpoint is a true PUT upsert
220 // (`PUT /origin/cloud_regions/{origin_ip}`), so a missing mapping
221 // and a drifted mapping converge through the same call. Skip the
222 // API entirely on a no-op.
223 if (
224 observed !== undefined &&
225 observed.vendor === news.vendor &&
226 observed.region === news.region
227 ) {
228 return observed;
229 }
230 const upserted = yield* cache.putOriginCloudRegion({
231 zoneId,
232 // Path param and body `origin_ip` must match or Cloudflare rejects
233 // the request with a 400.
234 originIP: ip,
235 originIp: ip,
236 vendor: news.vendor,
237 region: news.region,
238 });
239 return toAttributes(zoneId, upserted);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 // Idempotent — the mapping may already be gone (typed not-found), or
244 // the zone itself may have been deleted out-of-band (InvalidRoute).
245 yield* cache
246 .deleteOriginCloudRegion({
247 zoneId: output.zoneId,
248 originIP: output.originIp,
249 })
250 .pipe(
251 Effect.catchTag("OriginCloudRegionNotFound", () => Effect.void),
252 Effect.catchTag("InvalidRoute", () => Effect.void),
253 );
254 }),
255 });
256
257/**
258 * Read a mapping by IP, mapping "gone" (typed `OriginCloudRegionNotFound`,
259 * or `InvalidRoute` when the zone itself no longer routes) to `undefined`.
260 */
261const getMapping = (zoneId: string, ip: string) =>
262 cache.getOriginCloudRegion({ zoneId, originIP: ip }).pipe(
263 Effect.map((response): OriginCloudRegionAttributes | undefined =>
264 toAttributes(zoneId, response),
265 ),
266 Effect.catchTag("OriginCloudRegionNotFound", () =>
267 Effect.succeed(undefined),
268 ),
269 );
270
271const toAttributes = (
272 zoneId: string,
273 response:
274 | cache.GetOriginCloudRegionResponse
275 | cache.PutOriginCloudRegionResponse
276 | cache.ListOriginCloudRegionsResponse["result"][number],
277): OriginCloudRegionAttributes => ({
278 zoneId,
279 originIp: response.originIp,
280 vendor: response.vendor,
281 region: response.region,
282 modifiedOn: response.modifiedOn ?? undefined,
283});