| 1 | import * as pageRules from "@distilled.cloud/cloudflare/page-rules"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { listAllZones } from "../Zone/lookup.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.PageRule.PageRule" as const; |
| 13 | type 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 | */ |
| 24 | export type Action = pageRules.CreatePageRuleRequest["actions"][number]; |
| 25 | |
| 26 | /** |
| 27 | * Whether the rule is evaluated (`active`) or kept but ignored |
| 28 | * (`disabled`). |
| 29 | */ |
| 30 | export type Status = "active" | "disabled"; |
| 31 | |
| 32 | export 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 | |
| 74 | export 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 | |
| 93 | export 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 | */ |
| 174 | export const PageRule = Resource<PageRule>(TypeId, { |
| 175 | aliases: ["Cloudflare.PageRule"], |
| 176 | }); |
| 177 | |
| 178 | /** |
| 179 | * Returns true if the given value is a PageRule resource. |
| 180 | */ |
| 181 | export const isPageRule = (value: unknown): value is PageRule => |
| 182 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 183 | |
| 184 | export 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 | |
| 323 | type ObservedRule = pageRules.GetPageRuleResponse; |
| 324 | |
| 325 | /** |
| 326 | * Read a rule by id, mapping "gone" (typed `PageRuleNotFound`, HTTP 404) |
| 327 | * to `undefined`. |
| 328 | */ |
| 329 | const 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 | */ |
| 340 | const 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). */ |
| 353 | const 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. */ |
| 366 | const 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 | */ |
| 378 | const 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 | |
| 391 | const 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. */ |
| 401 | const 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 | |
| 408 | const 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 | }); |