1import * as cache from "@distilled.cloud/cloudflare/cache";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { arrayEquals } from "../../Util/equal.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.Cache.Variants" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * The file extensions the Variants setting can configure. Each key maps to
17 * the list of additional content types Cloudflare may serve (and cache) for
18 * URLs ending in that extension, e.g. `jpeg: ["image/webp", "image/avif"]`.
19 */
20export interface VariantsValue {
21 /** Additional content types to allow for `.avif` URLs. */
22 avif?: string[];
23 /** Additional content types to allow for `.bmp` URLs. */
24 bmp?: string[];
25 /** Additional content types to allow for `.gif` URLs. */
26 gif?: string[];
27 /** Additional content types to allow for `.jp2` URLs. */
28 jp2?: string[];
29 /** Additional content types to allow for `.jpeg` URLs. */
30 jpeg?: string[];
31 /** Additional content types to allow for `.jpg` URLs. */
32 jpg?: string[];
33 /** Additional content types to allow for `.jpg2` URLs. */
34 jpg2?: string[];
35 /** Additional content types to allow for `.png` URLs. */
36 png?: string[];
37 /** Additional content types to allow for `.tif` URLs. */
38 tif?: string[];
39 /** Additional content types to allow for `.tiff` URLs. */
40 tiff?: string[];
41 /** Additional content types to allow for `.webp` URLs. */
42 webp?: string[];
43}
44
45export interface VariantsProps extends VariantsValue {
46 /**
47 * Zone whose Variants cache setting is managed. Stable — changing the
48 * zone triggers a replacement (the old zone's setting is deleted, the
49 * new zone's setting is created).
50 */
51 zoneId: string;
52}
53
54export interface VariantsAttributes {
55 /** Zone the setting belongs to. */
56 zoneId: string;
57 /** Resolved current value of the setting (per-extension content types). */
58 value: VariantsValue;
59 /**
60 * Whether the setting can be modified on the zone's current plan
61 * (`false` means the setting is plan-gated).
62 */
63 editable: boolean;
64 /** When the setting was last modified, if Cloudflare reports it. */
65 modifiedOn: string | undefined;
66}
67
68export type Variants = Resource<
69 TypeId,
70 VariantsProps,
71 VariantsAttributes,
72 never,
73 Providers
74>;
75
76/**
77 * The Variants cache setting of a Cloudflare zone
78 * (`/zones/{zone_id}/cache/variants`).
79 *
80 * Variants lets a zone cache and serve multiple content types for the same
81 * URL — e.g. serve `image/webp` to browsers that accept it for a `.jpeg`
82 * URL — which is the setting behind "Serve WebP/AVIF to supported clients"
83 * workflows (typically combined with an image-resizing origin or worker).
84 *
85 * Unlike most zone cache settings, Variants has true create/delete
86 * semantics: the setting does not exist until it is first written
87 * (reads return a typed `VariantsNotConfigured` error), and `DELETE`
88 * removes it entirely, restoring the zone's default behavior. This
89 * resource therefore creates the setting on first deploy and deletes it
90 * on destroy.
91 *
92 * Only one `Variants` resource per zone makes sense — the setting is a
93 * zone singleton, and two instances managing the same zone would fight
94 * over it.
95 * ### Managing Variants
96 * **Example:** Serve WebP for JPEG URLs
97 * ```typescript
98 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
99 *
100 * yield* Cloudflare.Cache.Variants("ImageVariants", {
101 * zoneId: zone.zoneId,
102 * jpeg: ["image/webp"],
103 * jpg: ["image/webp"],
104 * });
105 * ```
106 *
107 * **Example:** Allow WebP and AVIF for all common image extensions
108 * ```typescript
109 * yield* Cloudflare.Cache.Variants("ImageVariants", {
110 * zoneId: zone.zoneId,
111 * jpeg: ["image/webp", "image/avif"],
112 * jpg: ["image/webp", "image/avif"],
113 * png: ["image/webp", "image/avif"],
114 * gif: ["image/webp", "image/avif"],
115 * });
116 * ```
117 *
118 * @see https://developers.cloudflare.com/cache/advanced-configuration/variants/
119 *
120 * @resource
121 * @product Cache
122 * @category Performance & Reliability
123 */
124export const Variants = Resource<Variants>(TypeId);
125
126/**
127 * Returns true if the given value is a Variants resource.
128 */
129export const isVariants = (value: unknown): value is Variants =>
130 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
131
132/** The file-extension keys the Variants setting supports. */
133const EXTENSION_KEYS = [
134 "avif",
135 "bmp",
136 "gif",
137 "jp2",
138 "jpeg",
139 "jpg",
140 "jpg2",
141 "png",
142 "tif",
143 "tiff",
144 "webp",
145] as const;
146
147/**
148 * Build the desired `value` object from props: only extensions with a
149 * non-empty content-type list are sent (the PATCH replaces the full value,
150 * so omitted keys are unset).
151 */
152const desiredValue = (props: VariantsProps): VariantsValue => {
153 const value: VariantsValue = {};
154 for (const key of EXTENSION_KEYS) {
155 const types = props[key];
156 if (types !== undefined && types.length > 0) {
157 value[key] = [...types];
158 }
159 }
160 return value;
161};
162
163/**
164 * Normalize an observed `value` (whose entries may be `null` or readonly)
165 * into the attribute shape: `null`/empty entries dropped, arrays copied.
166 */
167const normalizeValue = (
168 observed: cache.GetVariantResponse["value"],
169): VariantsValue => {
170 const value: VariantsValue = {};
171 for (const key of EXTENSION_KEYS) {
172 const types = observed[key];
173 if (types !== null && types !== undefined && types.length > 0) {
174 value[key] = [...types];
175 }
176 }
177 return value;
178};
179
180/** Deep-equality of two variants values (per-extension, order-sensitive). */
181const valuesEqual = (a: VariantsValue, b: VariantsValue): boolean =>
182 EXTENSION_KEYS.every((key) => arrayEquals(a[key], b[key]));
183
184export const VariantsProvider = () =>
185 Provider.succeed(Variants, {
186 stables: ["zoneId"],
187
188 list: Effect.fn(function* () {
189 const { accountId } = yield* yield* CloudflareEnvironment;
190 // No account-wide API for this per-zone setting — enumerate every
191 // zone in the account and read its variants setting. Unlike most
192 // zone singletons, Variants has true create/delete semantics, so a
193 // zone that never configured it returns `VariantsNotConfigured`;
194 // those zones (and plan-gated / deleted zones) are skipped.
195 const allZones = yield* listAllZones(accountId);
196 const rows = yield* Effect.forEach(
197 allZones.map((zone) => zone.id),
198 (zoneId) =>
199 cache.getVariant({ zoneId }).pipe(
200 Effect.map((observed) => toAttributes(zoneId, observed)),
201 // Setting never configured on this zone — nothing to enumerate.
202 Effect.catchTag("VariantsNotConfigured", () =>
203 Effect.succeed(undefined),
204 ),
205 // Plan-gated zones reject the setting (`Forbidden`, code 1135
206 // "not available for your plan type") or the route entirely
207 // (`InvalidRoute`); skip them.
208 Effect.catchTag(["Forbidden", "InvalidRoute"], () =>
209 Effect.succeed(undefined),
210 ),
211 ),
212 { concurrency: 10 },
213 );
214 return rows.filter((row): row is VariantsAttributes => row !== undefined);
215 }),
216
217 diff: Effect.fn(function* ({ olds = {}, news, output }) {
218 const o = olds as VariantsProps;
219 const n = news as VariantsProps;
220 // zoneId is Input<string>; compare only once both sides are concrete.
221 const oldZoneId =
222 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
223 if (
224 oldZoneId !== undefined &&
225 typeof n.zoneId === "string" &&
226 oldZoneId !== n.zoneId
227 ) {
228 return { action: "replace" } as const;
229 }
230 return undefined;
231 }),
232
233 read: Effect.fn(function* ({ output, olds }) {
234 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
235 if (!zoneId) return undefined;
236 const observed = yield* cache.getVariant({ zoneId }).pipe(
237 // The setting has never been written (or was deleted) — gone.
238 Effect.catchTag("VariantsNotConfigured", () =>
239 Effect.succeed(undefined),
240 ),
241 // Zone deleted out-of-band — the setting is gone with it.
242 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
243 );
244 if (observed === undefined) return undefined;
245 // The setting is a zone singleton with no ownership tags — a cold
246 // read adopts freely; reconcile converges it to the desired value.
247 return toAttributes(zoneId, observed);
248 }),
249
250 reconcile: Effect.fn(function* ({ news }) {
251 // Inputs have been resolved to concrete strings by Plan.
252 const zoneId = news.zoneId as string;
253
254 // 1. Observe — the setting may not exist yet (typed 404).
255 const observed = yield* cache
256 .getVariant({ zoneId })
257 .pipe(
258 Effect.catchTag("VariantsNotConfigured", () =>
259 Effect.succeed(undefined),
260 ),
261 );
262
263 // 2. Sync — PATCH both creates and replaces the full value, so a
264 // single upsert call converges missing and divergent states.
265 // Skip the API entirely when the observed value already matches.
266 const desired = desiredValue(news);
267 if (
268 observed !== undefined &&
269 valuesEqual(normalizeValue(observed.value), desired)
270 ) {
271 return toAttributes(zoneId, observed);
272 }
273 const patched = yield* cache.patchVariant({ zoneId, value: desired });
274 return toAttributes(zoneId, patched);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 const { zoneId } = output;
279 // DELETE removes the setting; tolerate the already-gone cases so
280 // re-delete after a crashed run (or an out-of-band zone delete)
281 // succeeds idempotently.
282 yield* cache.deleteVariant({ zoneId }).pipe(
283 Effect.catchTag("VariantsNotConfigured", () => Effect.void),
284 Effect.catchTag("InvalidRoute", () => Effect.void),
285 );
286 }),
287 });
288
289const toAttributes = (
290 zoneId: string,
291 setting: cache.GetVariantResponse | cache.PatchVariantResponse,
292): VariantsAttributes => ({
293 zoneId,
294 value: normalizeValue(setting.value),
295 editable: setting.editable,
296 modifiedOn: setting.modifiedOn ?? undefined,
297});