1import * as urlNormalization from "@distilled.cloud/cloudflare/url-normalization";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5
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.UrlNormalization.UrlNormalization" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Which URLs Cloudflare normalizes.
17 *
18 * - `"incoming"` — normalize the URLs used for rule matching only
19 * (Cloudflare's zone default).
20 * - `"both"` — additionally normalize the URLs forwarded to the origin.
21 * - `"none"` — disable URL normalization entirely.
22 */
23export type UrlNormalizationScope = "incoming" | "both" | "none";
24
25/**
26 * Which normalization algorithm Cloudflare applies.
27 *
28 * - `"cloudflare"` — RFC 3986 normalization plus Cloudflare's extra
29 * normalizations (e.g. collapsing `//` sequences). The zone default.
30 * - `"rfc3986"` — strict RFC 3986 normalization only.
31 */
32export type Type = "cloudflare" | "rfc3986";
33
34export interface Props {
35 /**
36 * Zone whose URL normalization is managed. Stable — changing the zone
37 * triggers a replacement (the old zone's URL normalization is reset to
38 * Cloudflare defaults as the old instance deletes).
39 */
40 zoneId: string;
41 /**
42 * The scope of the URL normalization: `"incoming"` normalizes URLs used
43 * for rule matching only, `"both"` also normalizes URLs sent to the
44 * origin, `"none"` disables normalization.
45 *
46 * Mutable — updated in place.
47 *
48 * @default "incoming"
49 */
50 scope?: UrlNormalizationScope;
51 /**
52 * The type of URL normalization performed by Cloudflare:
53 * `"cloudflare"` is RFC 3986 plus extra normalizations (e.g. `//`
54 * collapsing), `"rfc3986"` is strict RFC 3986 only.
55 *
56 * Mutable — updated in place.
57 *
58 * @default "cloudflare"
59 */
60 type?: Type;
61}
62
63export interface Attributes {
64 /** Zone whose URL normalization is managed. */
65 zoneId: string;
66 /** Observed scope of the URL normalization. */
67 scope: string;
68 /** Observed type of URL normalization performed by Cloudflare. */
69 type: string;
70}
71
72export type UrlNormalization = Resource<
73 TypeId,
74 Props,
75 Attributes,
76 never,
77 Providers
78>;
79
80/**
81 * The URL normalization configuration of a Cloudflare zone
82 * (`/zones/{zone_id}/url_normalization`) — a zone-scoped **singleton**
83 * controlling how Cloudflare normalizes incoming URLs before rule matching
84 * and before forwarding to the origin.
85 *
86 * The setting always exists on every zone (with Cloudflare defaults
87 * `scope: "incoming"`, `type: "cloudflare"`), so reconcile adopts the
88 * singleton and PUTs the desired `{ scope, type }` only when the observed
89 * configuration differs. Destroy issues the API's true reset operation
90 * (DELETE), returning the zone to Cloudflare defaults.
91 * ### Managing URL normalization
92 * **Example:** Normalize URLs sent to the origin too
93 * ```typescript
94 * yield* Cloudflare.UrlNormalization.UrlNormalization("UrlNormalization", {
95 * zoneId: zone.zoneId,
96 * scope: "both",
97 * });
98 * ```
99 *
100 * **Example:** Strict RFC 3986 normalization
101 * ```typescript
102 * yield* Cloudflare.UrlNormalization.UrlNormalization("UrlNormalization", {
103 * zoneId: zone.zoneId,
104 * scope: "incoming",
105 * type: "rfc3986",
106 * });
107 * ```
108 *
109 * **Example:** Disable URL normalization
110 * ```typescript
111 * yield* Cloudflare.UrlNormalization.UrlNormalization("UrlNormalization", {
112 * zoneId: zone.zoneId,
113 * scope: "none",
114 * });
115 * ```
116 *
117 * @see https://developers.cloudflare.com/rules/normalization/
118 *
119 * @resource
120 * @product URL Normalization
121 * @category Rules & Configuration
122 */
123export const UrlNormalization = Resource<UrlNormalization>(TypeId, {
124 aliases: ["Cloudflare.UrlNormalization"],
125});
126
127/**
128 * Returns true if the given value is a UrlNormalization resource.
129 */
130export const isUrlNormalization = (value: unknown): value is UrlNormalization =>
131 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
132
133/** Cloudflare's zone default scope. */
134const DEFAULT_SCOPE: UrlNormalizationScope = "incoming";
135/** Cloudflare's zone default normalization type. */
136const DEFAULT_TYPE: Type = "cloudflare";
137
138export const UrlNormalizationProvider = () =>
139 Provider.succeed(UrlNormalization, {
140 nuke: { singleton: true },
141 stables: ["zoneId"],
142
143 list: Effect.fn(function* () {
144 const { accountId } = yield* yield* CloudflareEnvironment;
145 // No account-wide API for this zone singleton — enumerate every
146 // zone in the account and read its URL normalization (every zone
147 // has one).
148 const allZones = yield* listAllZones(accountId);
149 const rows = yield* Effect.forEach(
150 allZones.map((zone) => zone.id),
151 (zoneId) =>
152 urlNormalization.getUrlNormalization({ zoneId }).pipe(
153 // Cloudflare intermittently rejects a *valid* token with
154 // `Forbidden` (a transient edge auth failure). Retry with capped
155 // backoff rather than dropping the zone, so a genuinely accessible
156 // zone never falls out of the enumeration on a blip.
157 Effect.retry({
158 while: (e) => e._tag === "Forbidden",
159 schedule: Schedule.max([
160 Schedule.min([
161 Schedule.exponential("500 millis"),
162 Schedule.spaced("5 seconds"),
163 ]),
164 Schedule.recurs(8),
165 ]),
166 }),
167 Effect.map((observed) => toAttributes(zoneId, observed)),
168 // Plan-gated or partial zones reject the route; skip them.
169 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
170 ),
171 { concurrency: 10 },
172 );
173 return rows.filter((row): row is Attributes => row !== undefined);
174 }),
175
176 diff: Effect.fn(function* ({ olds = {}, news, output }) {
177 const o = olds as Props;
178 const n = news as Props;
179 // zoneId is Input<string>; compare only once both sides are concrete.
180 const oldZoneId =
181 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
182 if (
183 oldZoneId !== undefined &&
184 typeof n.zoneId === "string" &&
185 oldZoneId !== n.zoneId
186 ) {
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 if (!zoneId) return undefined;
195 // The setting is a singleton that always exists with Cloudflare
196 // defaults — there is nothing to "own", so a cold read adopts freely
197 // (never `Unowned`). A dead zone reads as gone.
198 const observed = yield* urlNormalization
199 .getUrlNormalization({ zoneId })
200 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
201 if (observed === undefined) return undefined;
202 return toAttributes(zoneId, observed);
203 }),
204
205 reconcile: Effect.fn(function* ({ news }) {
206 // Inputs have been resolved to concrete strings by Plan.
207 const zoneId = news.zoneId as string;
208 const desiredScope = news.scope ?? DEFAULT_SCOPE;
209 const desiredType = news.type ?? DEFAULT_TYPE;
210
211 // 1. Observe — the singleton always exists on a live zone.
212 const observed = yield* urlNormalization.getUrlNormalization({ zoneId });
213
214 // 2. Sync — PUT is a full replace of { scope, type }; skip the API
215 // entirely when the observed configuration already matches.
216 if (observed.scope === desiredScope && observed.type === desiredType) {
217 return toAttributes(zoneId, observed);
218 }
219 const updated = yield* urlNormalization.putUrlNormalization({
220 zoneId,
221 scope: desiredScope,
222 type: desiredType,
223 });
224 return toAttributes(zoneId, updated);
225 }),
226
227 delete: Effect.fn(function* ({ output }) {
228 // True reset operation — returns the zone to Cloudflare defaults.
229 // Idempotent: resetting an already-default zone succeeds, and a dead
230 // zone is treated as already reset.
231 yield* urlNormalization
232 .deleteUrlNormalization({ zoneId: output.zoneId })
233 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
234 }),
235 });
236
237const toAttributes = (
238 zoneId: string,
239 observed: urlNormalization.GetUrlNormalizationResponse,
240): Attributes => ({
241 zoneId,
242 scope: observed.scope,
243 type: observed.type,
244});