1import * as speed from "@distilled.cloud/cloudflare/speed";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.Speed.TestSchedule" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Region a scheduled Observatory (speed) test runs from. GCP-style region
18 * identifiers — Cloudflare keys schedules per `(url, region)` pair.
19 */
20export type TestRegion =
21 | "asia-east1"
22 | "asia-northeast1"
23 | "asia-northeast2"
24 | "asia-south1"
25 | "asia-southeast1"
26 | "australia-southeast1"
27 | "europe-north1"
28 | "europe-southwest1"
29 | "europe-west1"
30 | "europe-west2"
31 | "europe-west3"
32 | "europe-west4"
33 | "europe-west8"
34 | "europe-west9"
35 | "me-west1"
36 | "southamerica-east1"
37 | "us-central1"
38 | "us-east1"
39 | "us-east4"
40 | "us-south1"
41 | "us-west1";
42
43/**
44 * How often a scheduled test runs.
45 */
46export type TestFrequency = "DAILY" | "WEEKLY";
47
48export interface TestScheduleProps {
49 /**
50 * Zone the scheduled test belongs to.
51 *
52 * Immutable — moving a schedule to a different zone triggers a
53 * replacement.
54 */
55 zoneId: string;
56 /**
57 * The page URL to test, e.g. `example.com/` or `example.com/pricing`.
58 * Must be a page on the zone. Cloudflare normalizes a bare hostname to
59 * `host/` (trailing slash).
60 *
61 * Immutable — the URL is the schedule's identity (path parameter), so
62 * changing it triggers a replacement. Deliberately a plain `string` (not
63 * `string`) so it is statically knowable inside `diff`.
64 */
65 url: string;
66 /**
67 * Region the test runs from. Schedules are keyed per `(url, region)` —
68 * changing the region triggers a replacement (a new schedule in the new
69 * region is created and the old one deleted).
70 * @default "us-central1"
71 */
72 region?: TestRegion;
73 /**
74 * How often the test runs. The API has no update call, so changing the
75 * frequency is converged in place by deleting and re-creating the
76 * schedule (same identity — not a replacement).
77 * @default WEEKLY for free plans, DAILY for paid plans (API default)
78 */
79 frequency?: TestFrequency;
80}
81
82export interface TestScheduleAttributes {
83 /** Zone the schedule belongs to. */
84 zoneId: string;
85 /** The tested page URL as normalized by Cloudflare (e.g. `example.com/`). */
86 url: string;
87 /** Region the test runs from. */
88 region: TestRegion;
89 /** How often the test runs. */
90 frequency: TestFrequency;
91}
92
93export type TestSchedule = Resource<
94 TypeId,
95 TestScheduleProps,
96 TestScheduleAttributes,
97 never,
98 Providers
99>;
100
101/**
102 * A recurring Cloudflare Observatory (Speed) test schedule — Cloudflare runs
103 * a Lighthouse test against a page on your zone on a `DAILY` or `WEEKLY`
104 * cadence from a region of your choice.
105 *
106 * A schedule's identity is the `(zoneId, url, region)` triple: the API keys
107 * schedules per URL and region, rejects duplicates, and has no update call.
108 * Changing `url`, `region`, or `zoneId` therefore replaces the schedule,
109 * while a `frequency` change is converged in place (delete + re-create under
110 * the same identity). Creating a schedule implicitly enqueues an initial
111 * test run.
112 *
113 * Plan quota applies: the number of allowed schedules per zone depends on
114 * the zone's plan (e.g. 5 on Free/Pro) — see the Observatory availabilities
115 * endpoint for the zone's remaining quota.
116 *
117 * Safety: schedules carry no ownership markers. When there is no prior
118 * state, `read` reports an existing schedule for the same `(url, region)` as
119 * `Unowned`, so the engine refuses to take it over unless `--adopt` (or
120 * `adopt(true)`) is set.
121 * ### Scheduling a test
122 * **Example:** Weekly test of the home page
123 * ```typescript
124 * yield* Cloudflare.Speed.TestSchedule("HomePageSpeed", {
125 * zoneId: zone.zoneId,
126 * url: "example.com/",
127 * frequency: "WEEKLY",
128 * });
129 * ```
130 *
131 * **Example:** Daily test of a specific page from Europe
132 * ```typescript
133 * yield* Cloudflare.Speed.TestSchedule("PricingSpeedEU", {
134 * zoneId: zone.zoneId,
135 * url: "example.com/pricing",
136 * region: "europe-west2",
137 * frequency: "DAILY",
138 * });
139 * ```
140 *
141 * @see https://developers.cloudflare.com/speed/speed-test/
142 *
143 * @resource
144 * @product Speed
145 * @category Performance & Reliability
146 */
147export const TestSchedule = Resource<TestSchedule>(TypeId);
148
149/**
150 * Returns true if the given value is a TestSchedule resource.
151 */
152export const isTestSchedule = (value: unknown): value is TestSchedule =>
153 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
154
155const DEFAULT_REGION: TestRegion = "us-central1";
156
157/**
158 * Cloudflare normalizes schedule URLs: the scheme is dropped and a bare
159 * hostname gains a trailing slash (`example.com` -> `example.com/`). Apply
160 * the same normalization locally so `diff` doesn't flag a replacement for a
161 * cosmetic respelling of the same page.
162 */
163const normalizeUrl = (url: string): string => {
164 const stripped = url.replace(/^https?:\/\//, "");
165 return stripped.includes("/") ? stripped : `${stripped}/`;
166};
167
168export const TestScheduleProvider = () =>
169 Provider.succeed(TestSchedule, {
170 stables: ["zoneId", "url", "region"],
171
172 list: Effect.fn(function* () {
173 const { accountId } = yield* yield* CloudflareEnvironment;
174 // Schedules live inside a zone and are keyed per `(url, region)` —
175 // there is no account-wide enumeration API. Fan out over every zone,
176 // enumerate the zone's Observatory pages, and resolve each page's
177 // schedule with the exact `(url, region)` keying `read` uses.
178 const zones = yield* listAllZones(accountId);
179 const rows = yield* Effect.forEach(
180 zones,
181 (zone) =>
182 speed.listPages.pages({ zoneId: zone.id }).pipe(
183 Stream.runCollect,
184 Effect.map((chunk) =>
185 Array.from(chunk).flatMap((page) =>
186 (page.result ?? []).flatMap((p) =>
187 p.url
188 ? [
189 {
190 url: p.url,
191 region:
192 (p.region?.value as TestRegion | undefined) ??
193 DEFAULT_REGION,
194 },
195 ]
196 : [],
197 ),
198 ),
199 ),
200 Effect.flatMap((pages) =>
201 Effect.forEach(
202 pages,
203 ({ url, region }) =>
204 getSchedule(zone.id, url, region).pipe(
205 Effect.map((observed) =>
206 observed
207 ? toAttributes(observed, zone.id, url, region)
208 : undefined,
209 ),
210 // Plan-gated zone rejects the schedule route; skip it.
211 Effect.catchTag("Forbidden", () =>
212 Effect.succeed(undefined),
213 ),
214 ),
215 { concurrency: 10 },
216 ),
217 ),
218 Effect.map((items) =>
219 items.filter(
220 (item): item is TestScheduleAttributes => item !== undefined,
221 ),
222 ),
223 // Plan-gated / partial zones reject the pages route; skip them.
224 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
225 ),
226 { concurrency: 10 },
227 );
228 return rows.flat();
229 }),
230
231 diff: Effect.fn(function* ({ olds, news }) {
232 const o = olds as TestScheduleProps | undefined;
233 const n = news as TestScheduleProps;
234 // No prior props to compare against — let the engine decide.
235 if (o?.url === undefined) return undefined;
236 // The URL is the schedule's path identity.
237 if (normalizeUrl(o.url) !== normalizeUrl(n.url)) {
238 return { action: "replace" } as const;
239 }
240 // Schedules are keyed per region — a region change is a new schedule.
241 if ((o.region ?? DEFAULT_REGION) !== (n.region ?? DEFAULT_REGION)) {
242 return { action: "replace" } as const;
243 }
244 // zoneId is Input<string>; compare only once both are concrete.
245 if (
246 typeof o.zoneId === "string" &&
247 typeof n.zoneId === "string" &&
248 o.zoneId !== n.zoneId
249 ) {
250 return { action: "replace" } as const;
251 }
252 // frequency converges in place (reconcile deletes + re-creates).
253 return undefined;
254 }),
255
256 read: Effect.fn(function* ({ output, olds }) {
257 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
258 const url = output?.url ?? olds?.url;
259 const region =
260 output?.region ?? (olds?.region as TestRegion) ?? DEFAULT_REGION;
261 if (!zoneId || !url) return undefined;
262
263 const observed = yield* getSchedule(zoneId, url, region);
264 if (!observed) return undefined;
265
266 const attrs = toAttributes(observed, zoneId, url, region);
267 // Owned path: we have persisted state for this schedule.
268 if (output) return attrs;
269 // Adoption path: a schedule for this `(url, region)` already exists
270 // but schedules carry no ownership markers, so we cannot prove we
271 // created it — brand it `Unowned` so the engine refuses to take over
272 // unless `adopt` is set.
273 return Unowned(attrs);
274 }),
275
276 reconcile: Effect.fn(function* ({ news }) {
277 // Inputs have been resolved to concrete strings by Plan.
278 const zoneId = news.zoneId as string;
279 const region = news.region ?? DEFAULT_REGION;
280
281 // 1. Observe — cloud state is authoritative. `output` is only a cache
282 // of the stable identity, which `news` fully determines anyway.
283 let observed = yield* getSchedule(zoneId, news.url, region);
284
285 // 2. Ensure — create when missing. A concurrent create surfaces as
286 // `TestScheduleAlreadyExists`: converge by re-reading the schedule
287 // that won the race.
288 if (!observed) {
289 observed = yield* createAndObserve(
290 zoneId,
291 news.url,
292 region,
293 news.frequency,
294 );
295 }
296
297 // 3. Sync — the only mutable aspect is `frequency`, and the API has
298 // no update call: converge drift by deleting and re-creating the
299 // schedule under the same identity. Skip entirely on a no-op (or
300 // when the user left frequency to the API default).
301 if (
302 news.frequency !== undefined &&
303 observed.frequency !== news.frequency
304 ) {
305 const previous = observed;
306 yield* speed
307 .deleteSchedule({ zoneId, url: news.url, region })
308 .pipe(Effect.catchTag("TestScheduleNotFound", () => Effect.void));
309 observed = yield* createAndObserve(
310 zoneId,
311 news.url,
312 region,
313 news.frequency,
314 ).pipe(
315 // Cloudflare caps DAILY-schedule creations per URL per day. We
316 // already deleted the old schedule — restore it so a quota
317 // rejection degrades to "unchanged" rather than "lost", then
318 // surface the typed error.
319 Effect.catchTag("TestScheduleQuotaReached", (error) =>
320 createAndObserve(
321 zoneId,
322 news.url,
323 region,
324 (previous.frequency ?? undefined) as TestFrequency | undefined,
325 ).pipe(Effect.flatMap(() => Effect.fail(error))),
326 ),
327 );
328 }
329
330 return toAttributes(observed, zoneId, news.url, region);
331 }),
332
333 delete: Effect.fn(function* ({ output }) {
334 // The API is idempotent server-side (DELETE on a missing schedule
335 // still answers 200), but tolerate the typed not-found anyway.
336 yield* speed
337 .deleteSchedule({
338 zoneId: output.zoneId,
339 url: output.url,
340 region: output.region,
341 })
342 .pipe(Effect.catchTag("TestScheduleNotFound", () => Effect.void));
343 }),
344 });
345
346/** The observed shape shared by get and create responses. */
347interface ObservedSchedule {
348 url?: string | null;
349 region?: string | null;
350 frequency?: string | null;
351}
352
353/**
354 * Read a schedule, mapping "gone" (`TestScheduleNotFound`, Cloudflare
355 * `speed.errors.schedule_not_found`) to `undefined`.
356 */
357const getSchedule = (zoneId: string, url: string, region: TestRegion) =>
358 speed.getSchedule({ zoneId, url, region }).pipe(
359 Effect.map((s): ObservedSchedule | undefined => s),
360 Effect.catchTag("TestScheduleNotFound", () => Effect.succeed(undefined)),
361 );
362
363/**
364 * Create a schedule and return the observed result. A concurrent create
365 * surfaces as `TestScheduleAlreadyExists` — converge by re-reading the
366 * schedule that won the race (and fail with the original error if it
367 * vanished again in between).
368 */
369const createAndObserve = (
370 zoneId: string,
371 url: string,
372 region: TestRegion,
373 frequency: TestFrequency | undefined,
374) =>
375 speed.createSchedule({ zoneId, url, region, frequency }).pipe(
376 Effect.flatMap((created) =>
377 created.schedule
378 ? Effect.succeed(created.schedule as ObservedSchedule)
379 : // The create succeeded but echoed no schedule — re-observe.
380 getScheduleOrFail(zoneId, url, region),
381 ),
382 Effect.catchTag("TestScheduleAlreadyExists", () =>
383 getScheduleOrFail(zoneId, url, region),
384 ),
385 );
386
387const getScheduleOrFail = (zoneId: string, url: string, region: TestRegion) =>
388 // Let the typed `TestScheduleNotFound` propagate — reaching this state
389 // means the schedule vanished between our calls; surfacing the typed
390 // error is more honest than inventing one.
391 speed
392 .getSchedule({ zoneId, url, region })
393 .pipe(Effect.map((s): ObservedSchedule => s));
394
395const toAttributes = (
396 observed: ObservedSchedule,
397 zoneId: string,
398 url: string,
399 region: TestRegion,
400): TestScheduleAttributes => ({
401 zoneId,
402 // Prefer the server-normalized URL (e.g. trailing slash added).
403 url: observed.url ?? normalizeUrl(url),
404 region: (observed.region ?? region) as TestRegion,
405 // Cloudflare always echoes a frequency for a persisted schedule; the
406 // distilled type is open/nullable, so default defensively.
407 frequency: (observed.frequency ?? "WEEKLY") as TestFrequency,
408});