1import * as healthchecks from "@distilled.cloud/cloudflare/healthchecks";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { arrayEqualsUnordered } from "../../Util/equal.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14import { listAllZones } from "../Zone/lookup.ts";
15
16const TypeId = "Cloudflare.Healthcheck.Healthcheck" as const;
17type TypeId = typeof TypeId;
18
19/**
20 * Protocol used by a standalone health check probe.
21 */
22export type Type = "HTTP" | "HTTPS" | "TCP";
23
24/**
25 * Region a health check can probe from. `null`/omitted lets Cloudflare pick
26 * a default region; multiple regions require a Business or Enterprise plan.
27 */
28export type Region =
29 | "WNAM"
30 | "ENAM"
31 | "WEU"
32 | "EEU"
33 | "NSAM"
34 | "SSAM"
35 | "OC"
36 | "ME"
37 | "NAF"
38 | "SAF"
39 | "IN"
40 | "SEAS"
41 | "NEAS"
42 | "ALL_REGIONS";
43
44/**
45 * Health status Cloudflare reports for the monitored origin. New checks
46 * start as `unknown` until the first probes complete.
47 */
48export type Status = "unknown" | "healthy" | "unhealthy" | "suspended";
49
50/**
51 * Parameters specific to an HTTP or HTTPS health check.
52 */
53export interface HttpConfig {
54 /**
55 * Do not validate the certificate when the health check uses HTTPS.
56 * @default false
57 */
58 allowInsecure?: boolean;
59 /**
60 * A case-insensitive substring to look for in the response body. If this
61 * string is not found, the origin will be marked as unhealthy.
62 */
63 expectedBody?: string;
64 /**
65 * The expected HTTP response codes (e.g. `"200"`) or code ranges
66 * (e.g. `"2xx"`) of the health check.
67 * @default ["200"]
68 */
69 expectedCodes?: string[];
70 /**
71 * Follow redirects if the origin returns a 3xx status code.
72 * @default false
73 */
74 followRedirects?: boolean;
75 /**
76 * HTTP request headers to send in the health check. It is recommended to
77 * set a `Host` header by default. The `User-Agent` header cannot be
78 * overridden.
79 */
80 header?: Record<string, string[]>;
81 /**
82 * The HTTP method to use for the health check.
83 * @default "GET"
84 */
85 method?: "GET" | "HEAD";
86 /**
87 * The endpoint path to health check against.
88 * @default "/"
89 */
90 path?: string;
91 /**
92 * Port number to connect to for the health check.
93 * @default 80 for HTTP, 443 for HTTPS
94 */
95 port?: number;
96}
97
98/**
99 * Parameters specific to a TCP health check.
100 */
101export interface TcpConfig {
102 /**
103 * The TCP connection method to use for the health check.
104 * @default "connection_established"
105 */
106 method?: "connection_established";
107 /**
108 * Port number to connect to for the health check.
109 * @default 80
110 */
111 port?: number;
112}
113
114export interface Props {
115 /**
116 * Zone the health check belongs to. Stable — changing the zone triggers
117 * a replacement.
118 */
119 zoneId: string;
120 /**
121 * A short name to identify the health check. Only alphanumeric
122 * characters, hyphens and underscores are allowed. If omitted, a unique
123 * name is generated from the app, stage, and logical ID.
124 *
125 * Mutable — Cloudflare supports renaming in place.
126 * @default ${app}-${stage}-${id}
127 */
128 name?: string;
129 /**
130 * The hostname or IP address of the origin server to run health checks
131 * on.
132 */
133 address: string;
134 /**
135 * The protocol to use for the health check. Currently supported
136 * protocols are `HTTP`, `HTTPS` and `TCP`.
137 * @default "HTTP"
138 */
139 type?: Type;
140 /**
141 * A human-readable description of the health check.
142 */
143 description?: string;
144 /**
145 * A list of regions from which to run health checks. Omitting it lets
146 * Cloudflare pick a default region. Multiple regions require a Business
147 * or Enterprise plan.
148 */
149 checkRegions?: Region[];
150 /**
151 * The number of consecutive fails required from a health check before
152 * changing the health to unhealthy.
153 * @default 1
154 */
155 consecutiveFails?: number;
156 /**
157 * The number of consecutive successes required from a health check
158 * before changing the health to healthy.
159 * @default 1
160 */
161 consecutiveSuccesses?: number;
162 /**
163 * The interval between each health check, in seconds. Shorter intervals
164 * may give quicker notifications but increase load on the origin.
165 * Plan-gated minimums apply (Pro: 60, Business: 15, Enterprise: 10).
166 * @default 60
167 */
168 interval?: number;
169 /**
170 * The number of retries to attempt in case of a timeout before marking
171 * the origin as unhealthy. Retries are attempted immediately.
172 * @default 2
173 */
174 retries?: number;
175 /**
176 * The timeout (in seconds) before marking the health check as failed.
177 * @default 5
178 */
179 timeout?: number;
180 /**
181 * If suspended, no health checks are sent to the origin.
182 * @default false
183 */
184 suspended?: boolean;
185 /**
186 * Parameters specific to an HTTP or HTTPS health check. Only valid when
187 * `type` is `HTTP` or `HTTPS`.
188 */
189 httpConfig?: HttpConfig;
190 /**
191 * Parameters specific to a TCP health check. Only valid when `type` is
192 * `TCP`.
193 */
194 tcpConfig?: TcpConfig;
195}
196
197export interface Attributes {
198 /** Cloudflare-assigned health check identifier. */
199 healthcheckId: string;
200 /** Zone that owns this health check. */
201 zoneId: string;
202 /** Health check name. */
203 name: string;
204 /** The hostname or IP address being monitored. */
205 address: string;
206 /** Probe protocol (`HTTP`, `HTTPS` or `TCP`). */
207 type: Type;
208 /** Current origin status according to the health check. */
209 status: Status;
210 /** The current failure reason if status is unhealthy. */
211 failureReason: string | undefined;
212 /** Whether probing is suspended. */
213 suspended: boolean;
214 /** Probe interval in seconds. */
215 interval: number;
216 /** Number of immediate retries on timeout. */
217 retries: number;
218 /** Probe timeout in seconds. */
219 timeout: number;
220 /** ISO8601 creation timestamp. */
221 createdOn: string | undefined;
222 /** ISO8601 last-modified timestamp. */
223 modifiedOn: string | undefined;
224}
225
226export type Healthcheck = Resource<TypeId, Props, Attributes, never, Providers>;
227
228/**
229 * A Cloudflare standalone Health Check — monitors an origin server from
230 * Cloudflare's edge and powers Health Check notifications and analytics.
231 *
232 * Zone-scoped and available on paid zone plans (Pro: 2 checks,
233 * Business: 10, Enterprise: more). Distinct from Load Balancing
234 * *Monitors*, which are account-scoped and attached to LB pools.
235 *
236 * Every property except `zoneId` is mutable in place (the API supports
237 * full PUT updates, including renames); changing the zone triggers a
238 * replacement.
239 *
240 * Safety: health checks carry no ownership markers, so when there is no
241 * prior state `read` matches by deterministic name and reports an
242 * existing check as `Unowned` — the engine refuses to take it over
243 * unless `--adopt` (or `adopt(true)`) is set.
244 * ### Creating a Health Check
245 * **Example:** Basic HTTP health check
246 * ```typescript
247 * const check = yield* Cloudflare.Healthcheck.Healthcheck("origin-check", {
248 * zoneId: zone.zoneId,
249 * address: "origin.example.com",
250 * });
251 * ```
252 *
253 * **Example:** HTTPS health check with custom path and expected codes
254 * ```typescript
255 * const check = yield* Cloudflare.Healthcheck.Healthcheck("api-health", {
256 * zoneId: zone.zoneId,
257 * address: "api.example.com",
258 * type: "HTTPS",
259 * interval: 60,
260 * retries: 2,
261 * timeout: 5,
262 * httpConfig: {
263 * path: "/healthz",
264 * expectedCodes: ["200"],
265 * followRedirects: true,
266 * },
267 * });
268 * ```
269 *
270 * ### TCP health checks
271 * **Example:** Probe a TCP port
272 * ```typescript
273 * const check = yield* Cloudflare.Healthcheck.Healthcheck("db-port", {
274 * zoneId: zone.zoneId,
275 * address: "db.example.com",
276 * type: "TCP",
277 * tcpConfig: { port: 5432 },
278 * });
279 * ```
280 *
281 * ### Suspending a check
282 * **Example:** Temporarily stop probing the origin
283 * ```typescript
284 * const check = yield* Cloudflare.Healthcheck.Healthcheck("origin-check", {
285 * zoneId: zone.zoneId,
286 * address: "origin.example.com",
287 * suspended: true,
288 * });
289 * ```
290 *
291 * @see https://developers.cloudflare.com/health-checks/
292 *
293 * @resource
294 * @product Health Checks
295 * @category Performance & Reliability
296 */
297export const Healthcheck = Resource<Healthcheck>(TypeId, {
298 aliases: ["Cloudflare.Healthcheck"],
299});
300
301/**
302 * Returns true if the given value is a Healthcheck resource.
303 */
304export const isHealthcheck = (value: unknown): value is Healthcheck =>
305 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
306
307export const HealthcheckProvider = () =>
308 Provider.succeed(Healthcheck, {
309 stables: ["healthcheckId", "zoneId", "createdOn"],
310
311 diff: Effect.fn(function* ({ olds = {}, news }) {
312 const o = olds as Props;
313 const n = news as Props;
314 // zoneId is Input<string>; by diff time both sides are concrete
315 // strings when statically known.
316 if (
317 typeof o.zoneId === "string" &&
318 typeof n.zoneId === "string" &&
319 o.zoneId !== n.zoneId
320 ) {
321 return { action: "replace" } as const;
322 }
323 }),
324
325 read: Effect.fn(function* ({ id, output, olds }) {
326 // Owned path: we have persisted state (our own id) — refresh it.
327 if (output?.healthcheckId) {
328 const observed = yield* getHealthcheck(
329 output.zoneId,
330 output.healthcheckId,
331 );
332 if (observed) return toAttributes(observed, output.zoneId);
333 return undefined;
334 }
335 // Adoption path: no state of our own, but a check with our
336 // deterministic name may already exist. Health checks carry no
337 // ownership markers we can inspect, so we cannot prove we created
338 // it — brand it `Unowned` so the engine refuses to take over
339 // unless `adopt` is set.
340 const zoneId = olds?.zoneId as string | undefined;
341 if (!zoneId) return undefined;
342 const name = yield* createHealthcheckName(id, olds?.name);
343 const match = yield* findByName(zoneId, name);
344 if (match) {
345 const attrs = toAttributes(match, zoneId);
346 if (attrs) return Unowned(attrs);
347 }
348 return undefined;
349 }),
350
351 reconcile: Effect.fn(function* ({ id, news, output }) {
352 // Inputs have been resolved to concrete strings by Plan.
353 const zoneId = news.zoneId as string;
354 const name = yield* createHealthcheckName(id, news.name);
355 const desired = buildDesiredBody(news, name);
356
357 // 1. Observe — the id cached on `output` is a hint, not a
358 // guarantee: a 404 falls through to "missing".
359 let observed: ObservedHealthcheck | undefined;
360 if (output?.healthcheckId) {
361 observed = yield* getHealthcheck(zoneId, output.healthcheckId);
362 }
363 // Fall back to matching by name (names are unique per zone), which
364 // also recovers from lost ids after the adopt gate has passed.
365 if (!observed) {
366 observed = yield* findByName(zoneId, name);
367 }
368
369 // 2. Ensure — create when missing; a concurrent create of the same
370 // name surfaces as `HealthcheckAlreadyExists`, which we treat as
371 // a race: re-read by name and converge via update below.
372 let justCreated = false;
373 if (!observed) {
374 observed = yield* healthchecks
375 .createHealthcheck({ zoneId, ...desired })
376 .pipe(
377 Effect.map((created): ObservedHealthcheck | undefined => created),
378 Effect.catchTag("HealthcheckAlreadyExists", () =>
379 findByName(zoneId, name),
380 ),
381 );
382 justCreated = observed !== undefined;
383 }
384
385 // 3. Sync — the update endpoint is a PUT that takes the full body;
386 // diff observed cloud state against desired and skip the call
387 // entirely on a no-op.
388 if (
389 observed?.id &&
390 !justCreated &&
391 !desiredEqualsObserved(desired, observed)
392 ) {
393 observed = yield* healthchecks.updateHealthcheck({
394 zoneId,
395 healthcheckId: observed.id,
396 ...desired,
397 });
398 }
399
400 // 4. Return.
401 const attrs = observed ? toAttributes(observed, zoneId) : undefined;
402 if (!attrs) {
403 return yield* Effect.fail(
404 new Error(
405 `Cloudflare did not return a usable health check for "${name}"`,
406 ),
407 );
408 }
409 return attrs;
410 }),
411
412 delete: Effect.fn(function* ({ output }) {
413 yield* healthchecks
414 .deleteHealthcheck({
415 zoneId: output.zoneId,
416 healthcheckId: output.healthcheckId,
417 })
418 .pipe(
419 // Already gone — deletion is idempotent.
420 Effect.catchTag("HealthcheckNotFound", () => Effect.void),
421 );
422 }),
423
424 // Health checks are zone-scoped (`/zones/{zone_id}/healthchecks`) with no
425 // account-wide enumeration API, so fan out over every zone and list per
426 // zone. A scoped token may lack permission on a zone (eventual consistency)
427 // or a zone may be partially provisioned — skip those zones (-> []) rather
428 // than failing the whole enumeration.
429 list: Effect.fn(function* () {
430 const { accountId } = yield* yield* CloudflareEnvironment;
431 const zones = yield* listAllZones(accountId);
432 const rows = yield* Effect.forEach(
433 zones,
434 (zone) =>
435 healthchecks.listHealthchecks.pages({ zoneId: zone.id }).pipe(
436 Stream.runCollect,
437 Effect.map((chunk) =>
438 Array.from(chunk).flatMap((page) =>
439 (page.result ?? []).flatMap((h) => {
440 const attrs = toAttributes(h, zone.id);
441 return attrs ? [attrs] : [];
442 }),
443 ),
444 ),
445 Effect.catchTag("Forbidden", () =>
446 Effect.succeed([] as Attributes[]),
447 ),
448 ),
449 { concurrency: 10 },
450 );
451 return rows.flat();
452 }),
453 });
454
455type ObservedHealthcheck = healthchecks.GetHealthcheckResponse;
456
457/**
458 * Read a health check by id, mapping "gone" (`HealthcheckNotFound`,
459 * HTTP 404) to `undefined`.
460 */
461const getHealthcheck = (zoneId: string, healthcheckId: string) =>
462 healthchecks
463 .getHealthcheck({ zoneId, healthcheckId })
464 .pipe(
465 Effect.catchTag("HealthcheckNotFound", () => Effect.succeed(undefined)),
466 );
467
468/**
469 * Find a health check by exact name. Names are unique per zone, so the
470 * first exact match is the resource.
471 */
472const findByName = (zoneId: string, name: string) =>
473 healthchecks.listHealthchecks.items({ zoneId }).pipe(
474 Stream.filter((h) => h.name === name),
475 Stream.runHead,
476 Effect.map((h): ObservedHealthcheck | undefined =>
477 Option.getOrUndefined(h),
478 ),
479 );
480
481const createHealthcheckName = (id: string, name: string | undefined) =>
482 Effect.gen(function* () {
483 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
484 });
485
486interface DesiredBody {
487 name: string;
488 address: string;
489 type: Type;
490 description?: string;
491 checkRegions?: Region[];
492 consecutiveFails: number;
493 consecutiveSuccesses: number;
494 interval: number;
495 retries: number;
496 timeout: number;
497 suspended: boolean;
498 httpConfig?: HttpConfig;
499 tcpConfig?: TcpConfig;
500}
501
502/**
503 * The full desired body sent on both create and PUT update. Documented
504 * Cloudflare defaults are filled in so the observed-vs-desired diff is
505 * exact and removing a prop converges back to the default.
506 */
507const buildDesiredBody = (news: Props, name: string): DesiredBody => ({
508 name,
509 address: news.address,
510 type: news.type ?? "HTTP",
511 description: news.description ?? "",
512 checkRegions: news.checkRegions,
513 consecutiveFails: news.consecutiveFails ?? 1,
514 consecutiveSuccesses: news.consecutiveSuccesses ?? 1,
515 interval: news.interval ?? 60,
516 retries: news.retries ?? 2,
517 timeout: news.timeout ?? 5,
518 suspended: news.suspended ?? false,
519 httpConfig: news.httpConfig,
520 tcpConfig: news.tcpConfig,
521});
522
523const desiredEqualsObserved = (
524 desired: DesiredBody,
525 observed: ObservedHealthcheck,
526): boolean => {
527 if (desired.name !== observed.name) return false;
528 if (desired.address !== observed.address) return false;
529 if (desired.type !== observed.type) return false;
530 if (desired.description !== (observed.description ?? "")) return false;
531 if (
532 desired.checkRegions !== undefined &&
533 !arrayEqualsUnordered(desired.checkRegions, observed.checkRegions ?? [])
534 ) {
535 return false;
536 }
537 if (desired.consecutiveFails !== (observed.consecutiveFails ?? 1)) {
538 return false;
539 }
540 if (desired.consecutiveSuccesses !== (observed.consecutiveSuccesses ?? 1)) {
541 return false;
542 }
543 if (desired.interval !== (observed.interval ?? 60)) return false;
544 if (desired.retries !== (observed.retries ?? 2)) return false;
545 if (desired.timeout !== (observed.timeout ?? 5)) return false;
546 if (desired.suspended !== (observed.suspended ?? false)) return false;
547 if (
548 desired.httpConfig !== undefined &&
549 !httpConfigEquals(desired.httpConfig, observed.httpConfig ?? undefined)
550 ) {
551 return false;
552 }
553 if (
554 desired.tcpConfig !== undefined &&
555 !tcpConfigEquals(desired.tcpConfig, observed.tcpConfig ?? undefined)
556 ) {
557 return false;
558 }
559 return true;
560};
561
562/**
563 * Compare only the http_config fields the user actually specified —
564 * Cloudflare fills the rest with plan/type-dependent defaults we should
565 * not fight.
566 */
567const httpConfigEquals = (
568 desired: HttpConfig,
569 observed: NonNullable<ObservedHealthcheck["httpConfig"]> | undefined,
570): boolean => {
571 if (observed === undefined) return false;
572 if (
573 desired.allowInsecure !== undefined &&
574 desired.allowInsecure !== (observed.allowInsecure ?? false)
575 ) {
576 return false;
577 }
578 if (
579 desired.expectedBody !== undefined &&
580 desired.expectedBody !== (observed.expectedBody ?? "")
581 ) {
582 return false;
583 }
584 if (
585 desired.expectedCodes !== undefined &&
586 !arrayEqualsUnordered(desired.expectedCodes, observed.expectedCodes ?? [])
587 ) {
588 return false;
589 }
590 if (
591 desired.followRedirects !== undefined &&
592 desired.followRedirects !== (observed.followRedirects ?? false)
593 ) {
594 return false;
595 }
596 if (desired.method !== undefined && desired.method !== observed.method) {
597 return false;
598 }
599 if (desired.path !== undefined && desired.path !== observed.path) {
600 return false;
601 }
602 if (desired.port !== undefined && desired.port !== observed.port) {
603 return false;
604 }
605 if (
606 desired.header !== undefined &&
607 !headerEquals(desired.header, observed.header)
608 ) {
609 return false;
610 }
611 return true;
612};
613
614const headerEquals = (
615 desired: Record<string, string[]>,
616 observed: Record<string, unknown> | null | undefined,
617): boolean => {
618 const obs = observed ?? {};
619 const keys = Object.keys(desired);
620 if (keys.length !== Object.keys(obs).length) return false;
621 return keys.every((k) => {
622 const o = obs[k];
623 return (
624 Array.isArray(o) && arrayEqualsUnordered(desired[k] ?? [], o.map(String))
625 );
626 });
627};
628
629const tcpConfigEquals = (
630 desired: TcpConfig,
631 observed: NonNullable<ObservedHealthcheck["tcpConfig"]> | undefined,
632): boolean => {
633 if (observed === undefined) return false;
634 if (
635 desired.method !== undefined &&
636 desired.method !== (observed.method ?? "connection_established")
637 ) {
638 return false;
639 }
640 if (desired.port !== undefined && desired.port !== observed.port) {
641 return false;
642 }
643 return true;
644};
645
646const toAttributes = (
647 observed: ObservedHealthcheck,
648 zoneId: string,
649): Attributes | undefined => {
650 if (!observed.id || !observed.name || !observed.address || !observed.type) {
651 return undefined;
652 }
653 return {
654 healthcheckId: observed.id,
655 zoneId,
656 name: observed.name,
657 address: observed.address,
658 type: observed.type as Type,
659 status: (observed.status ?? "unknown") as Status,
660 failureReason: observed.failureReason ?? undefined,
661 suspended: observed.suspended ?? false,
662 interval: observed.interval ?? 60,
663 retries: observed.retries ?? 2,
664 timeout: observed.timeout ?? 5,
665 createdOn: observed.createdOn ?? undefined,
666 modifiedOn: observed.modifiedOn ?? undefined,
667 };
668};