1import * as pageRules from "@distilled.cloud/cloudflare/page-rules";
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
12const TypeId = "Cloudflare.PageRule.PageRule" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * A single Page Rule action — a discriminated union over every setting a
17 * Page Rule can override (e.g. `{ id: "always_use_https" }`,
18 * `{ id: "cache_level", value: "cache_everything" }`,
19 * `{ id: "forwarding_url", value: { url, statusCode: "301" | "302" } }`).
20 *
21 * Note: `forwarding_url` cannot be combined with most other actions —
22 * a rule either redirects or overrides settings, not both.
23 */
24export type Action = pageRules.CreatePageRuleRequest["actions"][number];
25
26/**
27 * Whether the rule is evaluated (`active`) or kept but ignored
28 * (`disabled`).
29 */
30export type Status = "active" | "disabled";
31
32export interface Props {
33 /**
34 * Zone the Page Rule applies to. Stable — moving a rule between zones
35 * triggers a replacement.
36 */
37 zoneId: string;
38 /**
39 * The URL pattern the rule matches, e.g. `*.example.com/images/*`.
40 *
41 * Mutable — the API accepts a new target via PUT. Declared as plain
42 * `string` (not `string`) so it is statically knowable inside
43 * `diff` and usable as the rule's identity for stateless recovery.
44 */
45 target: string;
46 /**
47 * The set of actions to perform when the target matches. Actions can
48 * redirect to another URL (`forwarding_url`) or override settings,
49 * but not both. Mutable — synced in place via PUT.
50 */
51 actions: ReadonlyArray<Action>;
52 /**
53 * The priority of the rule relative to other Page Rules on the zone.
54 * A higher number indicates a higher priority. Mutable.
55 *
56 * Note: priority is positional — Cloudflare clamps it to the number of
57 * Page Rules on the zone (a lone rule is always priority `1`), so the
58 * echoed priority may be lower than requested.
59 *
60 * @default 1
61 */
62 priority?: number;
63 /**
64 * Whether the rule is evaluated. Mutable.
65 *
66 * Note: the raw Cloudflare API defaults to `"disabled"`; Alchemy
67 * defaults to `"active"` so a deployed rule takes effect immediately.
68 *
69 * @default "active"
70 */
71 status?: Status;
72}
73
74export interface Attributes {
75 /** Cloudflare-assigned identifier of the Page Rule. */
76 pageRuleId: string;
77 /** Zone the rule belongs to. */
78 zoneId: string;
79 /** The URL pattern the rule matches. */
80 target: string;
81 /** The actions the rule performs, as echoed by Cloudflare. */
82 actions: ReadonlyArray<Action>;
83 /** The rule's priority relative to other Page Rules on the zone. */
84 priority: number;
85 /** Whether the rule is evaluated (`active`) or ignored (`disabled`). */
86 status: Status;
87 /** ISO8601 creation timestamp. */
88 createdOn: string;
89 /** ISO8601 last-modified timestamp. */
90 modifiedOn: string;
91}
92
93export type PageRule = Resource<TypeId, Props, Attributes, never, Providers>;
94
95/**
96 * A Cloudflare **Page Rule** — a legacy zone-level rule that matches a URL
97 * pattern (`target`) and applies a set of `actions` (cache settings, SSL
98 * mode, redirects, etc.) at a given `priority`.
99 *
100 * :::caution[Legacy]
101 * Page Rules are a legacy product superseded by Cloudflare's modern Rules
102 * platform (Rulesets — Cache Rules, Redirect Rules, Configuration Rules,
103 * Origin Rules, Transform Rules). Prefer `Cloudflare.Ruleset.Ruleset` for new
104 * projects; use `Cloudflare.PageRule.PageRule` only for existing setups or
105 * migrations. Page Rules are also plan-limited (Free 3, Pro 20,
106 * Business 50, Enterprise 125).
107 * :::
108 *
109 * A rule's recoverable identity is its `target` URL pattern within the
110 * zone. Page Rules carry no ownership markers, so when there is no prior
111 * state `read` scans the zone for a rule with the same target and reports
112 * it as `Unowned` — the engine refuses to take it over unless `--adopt`
113 * (or `adopt(true)`) is set.
114 * ### Caching
115 * **Example:** Cache everything under a path
116 * ```typescript
117 * yield* Cloudflare.PageRule.PageRule("CacheImages", {
118 * zoneId: zone.zoneId,
119 * target: `${zone.name}/images/*`,
120 * actions: [
121 * { id: "cache_level", value: "cache_everything" },
122 * { id: "edge_cache_ttl", value: 7200 },
123 * ],
124 * });
125 * ```
126 *
127 * ### Redirects
128 * **Example:** Permanent redirect with forwarding_url
129 * ```typescript
130 * // forwarding_url cannot be combined with most other actions.
131 * yield* Cloudflare.PageRule.PageRule("RedirectOldBlog", {
132 * zoneId: zone.zoneId,
133 * target: `${zone.name}/blog/*`,
134 * actions: [
135 * {
136 * id: "forwarding_url",
137 * value: { url: "https://new.example.com/blog/$1", statusCode: "301" },
138 * },
139 * ],
140 * });
141 * ```
142 *
143 * ### Security
144 * **Example:** Force HTTPS and raise the security level
145 * ```typescript
146 * yield* Cloudflare.PageRule.PageRule("SecureAdmin", {
147 * zoneId: zone.zoneId,
148 * target: `${zone.name}/admin/*`,
149 * actions: [
150 * { id: "always_use_https" },
151 * { id: "security_level", value: "high" },
152 * ],
153 * priority: 2,
154 * });
155 * ```
156 *
157 * ### Staged rollout
158 * **Example:** Create the rule disabled, flip to active later
159 * ```typescript
160 * yield* Cloudflare.PageRule.PageRule("BypassCacheBeta", {
161 * zoneId: zone.zoneId,
162 * target: `${zone.name}/beta/*`,
163 * actions: [{ id: "cache_level", value: "bypass" }],
164 * status: "disabled",
165 * });
166 * ```
167 *
168 * @see https://developers.cloudflare.com/rules/page-rules/
169 *
170 * @resource
171 * @product Page Rules
172 * @category Rules & Configuration
173 */
174export const PageRule = Resource<PageRule>(TypeId, {
175 aliases: ["Cloudflare.PageRule"],
176});
177
178/**
179 * Returns true if the given value is a PageRule resource.
180 */
181export const isPageRule = (value: unknown): value is PageRule =>
182 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
183
184export const PageRuleProvider = () =>
185 Provider.succeed(PageRule, {
186 stables: ["pageRuleId", "zoneId", "createdOn"],
187
188 list: Effect.fn(function* () {
189 const { accountId } = yield* yield* CloudflareEnvironment;
190 // Page Rules live inside a zone (`/zones/{id}/pagerules`) with no
191 // account-wide enumeration API. Fan out over every zone and list
192 // its rules; `listPageRules` is non-paginated (returns the whole
193 // array in one response). Skip plan-gated zones with the typed
194 // `Forbidden` tag.
195 const zones = yield* listAllZones(accountId);
196 const rows = yield* Effect.forEach(
197 zones,
198 (zone) =>
199 pageRules.listPageRules({ zoneId: zone.id }).pipe(
200 Effect.map((rules) =>
201 rules.map((rule) => toAttributes(rule as ObservedRule, zone.id)),
202 ),
203 // Skip plan-gated zones (`Forbidden`) and zones Cloudflare rejects
204 // with "Invalid zone identifier" (e.g. pending/partial-setup zones
205 // that don't accept the page-rules endpoint) — they contribute no
206 // rules and shouldn't fail the whole account enumeration.
207 Effect.catchTag(["Forbidden", "InvalidZoneIdentifier"], () =>
208 Effect.succeed([]),
209 ),
210 ),
211 { concurrency: 10 },
212 );
213 return rows.flat();
214 }),
215
216 diff: Effect.fn(function* ({ olds = {}, news }) {
217 const o = olds as Props;
218 const n = news as Props;
219 // zoneId is Input<string>; compare only once both are concrete.
220 if (
221 typeof o.zoneId === "string" &&
222 typeof n.zoneId === "string" &&
223 o.zoneId !== n.zoneId
224 ) {
225 return { action: "replace" } as const;
226 }
227 // Everything else (target, actions, priority, status) is mutable
228 // via PUT — let the engine apply the default update logic.
229 return undefined;
230 }),
231
232 read: Effect.fn(function* ({ output, olds }) {
233 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
234 if (!zoneId) return undefined;
235
236 // Owned path: refresh by our persisted rule id.
237 if (output?.pageRuleId) {
238 const observed = yield* getRule(zoneId, output.pageRuleId);
239 if (observed) return toAttributes(observed, zoneId);
240 }
241
242 // Adoption path: a rule with this target may already exist on the
243 // zone. Page Rules carry no ownership markers, so we cannot prove
244 // we created it — brand it `Unowned` so the engine refuses to take
245 // over unless `adopt` is set.
246 const target = output?.target ?? olds?.target;
247 if (target) {
248 const observed = yield* findByTarget(zoneId, target);
249 if (observed) return Unowned(toAttributes(observed, zoneId));
250 }
251 return undefined;
252 }),
253
254 reconcile: Effect.fn(function* ({ news, output }) {
255 // Inputs have been resolved to concrete strings by Plan.
256 const zoneId = news.zoneId as string;
257 const desired = desiredBody(news);
258
259 // 1. Observe — the rule id cached on `output` is a hint, not a
260 // guarantee: a missing rule falls through to the target scan
261 // and then to create.
262 let observed = output?.pageRuleId
263 ? yield* getRule(zoneId, output.pageRuleId)
264 : undefined;
265
266 // 2. Fall back to scanning the zone for a rule with the same
267 // target. Ownership has already been verified upstream — `read`
268 // reports existing rules as `Unowned` and the engine gates
269 // takeover behind the adopt policy before reconcile ever runs.
270 if (!observed) {
271 observed = yield* findByTarget(zoneId, news.target);
272 }
273
274 // 3. Ensure — create when missing. Pass the desired status
275 // explicitly because the raw API defaults to "disabled".
276 if (!observed) {
277 observed = yield* pageRules.createPageRule({
278 zoneId,
279 ...desired,
280 });
281 }
282
283 // 4. Sync — diff observed against desired; the update endpoint is
284 // a PUT-style full replace, so resend the whole desired body
285 // when any mutable aspect differs. Skip the call on a no-op.
286 // A rule deleted between observe and update surfaces as the
287 // typed `PageRuleNotFound` — converge by re-creating.
288 if (!ruleMatchesDesired(observed, news)) {
289 observed = yield* pageRules
290 .updatePageRule({
291 zoneId,
292 pageruleId: observed.id,
293 ...desired,
294 })
295 .pipe(
296 Effect.catchTag("PageRuleNotFound", () =>
297 pageRules.createPageRule({ zoneId, ...desired }),
298 ),
299 );
300 }
301
302 // 5. Return the fresh attributes shape.
303 return toAttributes(observed, zoneId);
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 yield* pageRules
308 .deletePageRule({
309 zoneId: output.zoneId,
310 pageruleId: output.pageRuleId,
311 })
312 .pipe(
313 // Already gone — deletion is idempotent.
314 Effect.catchTag("PageRuleNotFound", () => Effect.void),
315 );
316 }),
317 });
318
319// ---------------------------------------------------------------------------
320// API helpers
321// ---------------------------------------------------------------------------
322
323type ObservedRule = pageRules.GetPageRuleResponse;
324
325/**
326 * Read a rule by id, mapping "gone" (typed `PageRuleNotFound`, HTTP 404)
327 * to `undefined`.
328 */
329const getRule = (zoneId: string, pageruleId: string) =>
330 pageRules.getPageRule({ zoneId, pageruleId }).pipe(
331 Effect.map((rule): ObservedRule | undefined => rule),
332 Effect.catchTag("PageRuleNotFound", () => Effect.succeed(undefined)),
333 );
334
335/**
336 * Find a rule on the zone whose URL-pattern target matches. The target is
337 * the rule's only recoverable identity — Cloudflare rejects a second rule
338 * with the same target, so at most one rule can match.
339 */
340const findByTarget = (zoneId: string, target: string) =>
341 pageRules
342 .listPageRules({ zoneId })
343 .pipe(
344 Effect.map(
345 (rules) =>
346 rules.find((rule) => targetOf(rule) === target) as
347 | ObservedRule
348 | undefined,
349 ),
350 );
351
352/** The desired request body (everything except path params). */
353const desiredBody = (news: Props) => ({
354 targets: [
355 {
356 target: "url" as const,
357 constraint: { operator: "matches" as const, value: news.target },
358 },
359 ],
360 actions: Array.from(news.actions),
361 priority: news.priority ?? 1,
362 status: news.status ?? ("active" as const),
363});
364
365/** Extract the URL pattern from a rule's targets. */
366const targetOf = (rule: {
367 targets: ReadonlyArray<{
368 constraint?: { value: string } | null;
369 }>;
370}): string | undefined => rule.targets[0]?.constraint?.value;
371
372/**
373 * Deep-canonicalise a JSON-ish value for comparison: drop `null` /
374 * `undefined` members and sort object keys. Action arrays are compared
375 * order-insensitively (sorted by action id) because Cloudflare reorders
376 * them on echo.
377 */
378const canonical = (v: unknown): unknown => {
379 if (Array.isArray(v)) return v.map(canonical);
380 if (v !== null && typeof v === "object") {
381 return Object.fromEntries(
382 Object.entries(v as Record<string, unknown>)
383 .filter(([, val]) => val !== undefined && val !== null)
384 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
385 .map(([k, val]) => [k, canonical(val)]),
386 );
387 }
388 return v;
389};
390
391const canonicalActions = (actions: ReadonlyArray<Action>): string =>
392 JSON.stringify(
393 actions
394 .map((a) => canonical(a) as { id?: string })
395 .sort((a, b) =>
396 (a.id ?? "") < (b.id ?? "") ? -1 : (a.id ?? "") > (b.id ?? "") ? 1 : 0,
397 ),
398 );
399
400/** True when the observed rule already matches every desired aspect. */
401const ruleMatchesDesired = (observed: ObservedRule, news: Props): boolean =>
402 targetOf(observed) === news.target &&
403 observed.priority === (news.priority ?? 1) &&
404 observed.status === (news.status ?? "active") &&
405 canonicalActions(observed.actions as ReadonlyArray<Action>) ===
406 canonicalActions(news.actions);
407
408const toAttributes = (rule: ObservedRule, zoneId: string): Attributes => ({
409 pageRuleId: rule.id,
410 zoneId,
411 target: targetOf(rule) ?? "",
412 actions: rule.actions as ReadonlyArray<Action>,
413 priority: rule.priority,
414 status: rule.status as Status,
415 createdOn: rule.createdOn,
416 modifiedOn: rule.modifiedOn,
417});