1import * as images from "@distilled.cloud/cloudflare/images";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
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";
12
13const TypeId = "Cloudflare.Images.Variant" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * How an image is resized to fit a variant's `width`x`height` box.
18 */
19export type VariantFit = "scale-down" | "contain" | "cover" | "crop" | "pad";
20
21/**
22 * What happens to the image's EXIF metadata when the variant is served.
23 */
24export type VariantMetadata = "keep" | "copyright" | "none";
25
26export interface VariantProps {
27 /**
28 * Account the variant is created in. Defaults to the ambient Cloudflare
29 * account. Changing it triggers a replacement.
30 */
31 accountId?: string;
32 /**
33 * Variant name — the URL segment used to request the variant (e.g.
34 * `https://imagedelivery.net/<hash>/<image>/<name>`). Alphanumeric only;
35 * Cloudflare rejects hyphens and underscores.
36 *
37 * Defaults to the logical ID verbatim (variant names are user-facing URL
38 * segments, so no uniqueness suffix is appended). Changing the name
39 * triggers a replacement — the API cannot rename a variant.
40 *
41 * @default the logical ID
42 */
43 name?: string;
44 /**
45 * How the image is resized to fit the `width`x`height` box. Mutable.
46 */
47 fit: VariantFit;
48 /**
49 * Maximum width in pixels (1-9999). Mutable.
50 */
51 width: number;
52 /**
53 * Maximum height in pixels (1-9999). Mutable.
54 */
55 height: number;
56 /**
57 * What EXIF metadata is preserved on the served image. Mutable.
58 * @default "none"
59 */
60 metadata?: VariantMetadata;
61 /**
62 * If `true`, this variant can serve an image without a signed URL even
63 * when the image itself requires signed URLs. Mutable.
64 * @default false
65 */
66 neverRequireSignedURLs?: boolean;
67}
68
69export interface VariantAttributes {
70 /** The variant's name (its API path identifier and URL segment). */
71 variantName: string;
72 /** Account the variant belongs to. */
73 accountId: string;
74 /** How the image is resized. */
75 fit: VariantFit;
76 /** Maximum width in pixels. */
77 width: number;
78 /** Maximum height in pixels. */
79 height: number;
80 /** EXIF metadata handling. */
81 metadata: VariantMetadata;
82 /** Whether the variant bypasses signed-URL requirements. */
83 neverRequireSignedURLs: boolean;
84}
85
86export type Variant = Resource<
87 TypeId,
88 VariantProps,
89 VariantAttributes,
90 never,
91 Providers
92>;
93
94/**
95 * A Cloudflare Images variant — a named resizing preset (e.g. `thumbnail`,
96 * `hero`) applied when serving images from Cloudflare Images.
97 *
98 * A variant is identified by its name within an account (up to 100 variants
99 * per account). The name is the URL segment used to request the variant, so
100 * it is immutable — changing it triggers a replacement. All resizing options
101 * (`fit`, `width`, `height`, `metadata`, `neverRequireSignedURLs`) are
102 * mutable in place.
103 *
104 * Note: every Images-enabled account has a built-in `public` variant. Do not
105 * manage `public` with this resource — Cloudflare silently ignores deletes
106 * of the built-in variant, so destroy would not actually remove it.
107 * ### Creating a Variant
108 * **Example:** Thumbnail variant
109 * ```typescript
110 * // Variant names are alphanumeric only (no hyphens/underscores).
111 * const thumbnail = yield* Cloudflare.Images.Variant("thumbnail", {
112 * fit: "cover",
113 * width: 100,
114 * height: 100,
115 * });
116 * ```
117 *
118 * **Example:** Hero variant with explicit name and metadata
119 * ```typescript
120 * const hero = yield* Cloudflare.Images.Variant("HeroImage", {
121 * name: "hero",
122 * fit: "scale-down",
123 * width: 1920,
124 * height: 1080,
125 * metadata: "copyright",
126 * });
127 * ```
128 *
129 * ### Signed URLs
130 * **Example:** Public variant for protected images
131 * ```typescript
132 * // Serve this variant without a signature even when the image itself
133 * // requires signed URLs (e.g. for public thumbnails of private images).
134 * const preview = yield* Cloudflare.Images.Variant("preview", {
135 * fit: "contain",
136 * width: 320,
137 * height: 240,
138 * neverRequireSignedURLs: true,
139 * });
140 * ```
141 *
142 * @see https://developers.cloudflare.com/images/manage-images/create-variants/
143 *
144 * @resource
145 * @product Images
146 * @category Media
147 */
148export const Variant = Resource<Variant>(TypeId);
149
150/**
151 * Returns true if the given value is an Variant resource.
152 */
153export const isVariant = (value: unknown): value is Variant =>
154 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
155
156export const VariantProvider = () =>
157 Provider.succeed(Variant, {
158 stables: ["variantName", "accountId"],
159
160 list: Effect.fn(function* () {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162
163 // Enumerate variant names from the account-scoped list endpoint, then
164 // hydrate each via the per-variant GET (whose schema is correct) into
165 // the exact `read` Attributes shape.
166 //
167 // NOTE: distilled mis-types this response as a single variant, but the
168 // real body is a keyed `variants` map — so the strict decode currently
169 // fails. The fix is a distilled response-schema patch (see neededPatch
170 // in the agent report); once applied, the successful-decode path below
171 // works unchanged.
172 const names = yield* images.listV1Variants({ accountId }).pipe(
173 Effect.map(variantNamesFrom),
174 // The built-in `public` variant is not managed by this resource —
175 // Cloudflare silently ignores deletes of it (the DELETE returns 200
176 // but the variant persists), so exclude it from enumeration.
177 Effect.map((all) => all.filter((name) => name !== "public")),
178 // Accounts without the Cloudflare Images entitlement reject the route
179 // (code 5403) — there is nothing to enumerate.
180 Effect.catchTag("ImagesAccessNotEnabled", () =>
181 Effect.succeed<string[]>([]),
182 ),
183 );
184
185 const rows = yield* Effect.forEach(
186 names,
187 (name) =>
188 getVariant(accountId, name).pipe(
189 Effect.map((variant) =>
190 variant ? toAttributes(variant, accountId) : undefined,
191 ),
192 ),
193 { concurrency: 10 },
194 );
195
196 return rows.filter((row): row is VariantAttributes => row !== undefined);
197 }),
198
199 diff: Effect.fn(function* ({ id, olds, news, output }) {
200 // News may still contain unresolved plan-time expressions — defer to
201 // the engine's default update logic until everything is concrete.
202 if (!isResolved(news)) return undefined;
203 // The variant name is its API path identifier — it cannot be renamed.
204 const oldName = output?.variantName ?? olds?.name ?? id;
205 if ((news.name ?? id) !== oldName) {
206 return { action: "replace" } as const;
207 }
208 // Moving accounts replaces. accountId is an Input — compare only once
209 // both sides are concrete strings.
210 if (
211 typeof olds?.accountId === "string" &&
212 typeof news.accountId === "string" &&
213 olds.accountId !== news.accountId
214 ) {
215 return { action: "replace" } as const;
216 }
217 if (
218 output?.accountId !== undefined &&
219 typeof news.accountId === "string" &&
220 news.accountId !== output.accountId
221 ) {
222 return { action: "replace" } as const;
223 }
224 return undefined;
225 }),
226
227 read: Effect.fn(function* ({ id, output, olds }) {
228 const { accountId } = yield* yield* CloudflareEnvironment;
229 const acct =
230 output?.accountId ??
231 (olds?.accountId as string | undefined) ??
232 accountId;
233 const name = output?.variantName ?? olds?.name ?? id;
234
235 const observed = yield* getVariant(acct, name);
236 if (!observed) return undefined;
237 const attrs = toAttributes(observed, acct);
238 // Variants carry no ownership markers. With no prior output we cannot
239 // prove we created a same-named variant — report it `Unowned` so the
240 // engine gates takeover behind the adopt policy.
241 return output?.variantName ? attrs : Unowned(attrs);
242 }),
243
244 reconcile: Effect.fn(function* ({ id, news }) {
245 const { accountId } = yield* yield* CloudflareEnvironment;
246 // Inputs have been resolved to concrete strings by Plan.
247 const acct = (news.accountId as string | undefined) ?? accountId;
248 const name = news.name ?? id;
249
250 // 1. Observe — cloud state is authoritative. The cached output is only
251 // a hint; a missing variant falls through to create.
252 let observed = yield* getVariant(acct, name);
253
254 // 2. Ensure — create when missing. A concurrent create surfaces as
255 // `VariantAlreadyExists` (Cloudflare code 5409): converge by
256 // re-reading the variant that won the race.
257 if (!observed) {
258 observed = yield* images
259 .createV1Variant({
260 accountId: acct,
261 id: name,
262 options: desiredOptions(news),
263 neverRequireSignedURLs: news.neverRequireSignedURLs,
264 })
265 .pipe(
266 Effect.map((created) => created.variant ?? undefined),
267 Effect.catchTag("VariantAlreadyExists", () =>
268 getVariant(acct, name),
269 ),
270 );
271 }
272 if (!observed) {
273 // Create succeeded but neither the response body nor a re-read
274 // surfaced the variant — eventual-consistency blip; fail typed so
275 // the engine can retry the reconcile.
276 return yield* Effect.fail(
277 new images.VariantNotFound({
278 code: 5401,
279 message: `variant ${name} not observable after create`,
280 }),
281 );
282 }
283
284 // 3. Sync — diff observed options against desired; PATCH requires the
285 // full options struct, so send everything, but skip the API call
286 // entirely on a no-op.
287 const desired = desiredOptions(news);
288 const desiredSigned = news.neverRequireSignedURLs ?? false;
289 const dirty =
290 observed.options.fit !== desired.fit ||
291 observed.options.width !== desired.width ||
292 observed.options.height !== desired.height ||
293 observed.options.metadata !== desired.metadata ||
294 (observed.neverRequireSignedURLs ?? false) !== desiredSigned;
295
296 if (dirty) {
297 const patched = yield* images.patchV1Variant({
298 accountId: acct,
299 variantId: name,
300 options: desired,
301 neverRequireSignedURLs: desiredSigned,
302 });
303 observed = patched.variant ?? observed;
304 }
305
306 return toAttributes(observed, acct);
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* images
311 .deleteV1Variant({
312 accountId: output.accountId,
313 variantId: output.variantName,
314 })
315 .pipe(Effect.catchTag("VariantNotFound", () => Effect.void));
316
317 // Cloudflare's variant DELETE is eventually consistent: the GET
318 // endpoint keeps returning the variant for a short window after a
319 // successful delete. Confirm it is actually gone before reporting
320 // success, so the engine's replacement GC (which deletes the OLD
321 // generation as the new one is created) leaves a genuinely clean slate
322 // rather than an orphan that lingers past the caller's verification.
323 // Bounded — if it never clears we stop polling and proceed.
324 yield* getVariant(output.accountId, output.variantName).pipe(
325 Effect.repeat({
326 schedule: Schedule.max([
327 Schedule.exponential("500 millis"),
328 Schedule.recurs(12),
329 ]),
330 until: (observed) => observed === undefined,
331 }),
332 );
333 }),
334 });
335
336type ObservedVariant = NonNullable<images.GetV1VariantResponse["variant"]>;
337
338/**
339 * Read a variant by name, mapping "gone" (`VariantNotFound` — Cloudflare
340 * error code 5401, or the envelope-less plain-text 404 the GET endpoint
341 * returns) to `undefined`.
342 */
343const getVariant = (accountId: string, variantId: string) =>
344 images.getV1Variant({ accountId, variantId }).pipe(
345 Effect.map((response) => response.variant ?? undefined),
346 Effect.catchTag("VariantNotFound", () => Effect.succeed(undefined)),
347 );
348
349/**
350 * Extract variant names — the keys of the `variants` map — from a list-variants
351 * payload. The endpoint returns `result.variants` as a keyed object map of
352 * variantId -> variant (not an array), so accept either the unwrapped `result`
353 * (`{ variants: {...} }`) or the full raw body (`{ result: { variants: {...} } }`).
354 */
355const variantNamesFrom = (value: unknown): string[] => {
356 const root =
357 Predicate.hasProperty(value, "result") &&
358 typeof value.result === "object" &&
359 value.result !== null
360 ? value.result
361 : value;
362 if (
363 Predicate.hasProperty(root, "variants") &&
364 typeof root.variants === "object" &&
365 root.variants !== null
366 ) {
367 return Object.keys(root.variants);
368 }
369 return [];
370};
371
372const desiredOptions = (news: VariantProps) => ({
373 fit: news.fit,
374 width: news.width,
375 height: news.height,
376 metadata: news.metadata ?? ("none" as const),
377});
378
379const toAttributes = (
380 variant: ObservedVariant,
381 accountId: string,
382): VariantAttributes => ({
383 variantName: variant.id,
384 accountId,
385 // Distilled widens generated string enums to open unions (`string & {}`).
386 fit: variant.options.fit as VariantFit,
387 width: variant.options.width,
388 height: variant.options.height,
389 metadata: variant.options.metadata as VariantMetadata,
390 neverRequireSignedURLs: variant.neverRequireSignedURLs ?? false,
391});