1import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
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 { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.LoadBalancer.Monitor" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Protocol a Load Balancing monitor probes origins with.
19 */
20export type MonitorType =
21 | "http"
22 | "https"
23 | "tcp"
24 | "udp_icmp"
25 | "icmp_ping"
26 | "smtp";
27
28export interface MonitorProps {
29 /**
30 * The protocol to use for the health check.
31 * @default "http"
32 */
33 type?: MonitorType;
34 /**
35 * Object description. Monitors have no name field, so the description
36 * doubles as the monitor's identity for state recovery — if omitted, a
37 * unique name is generated from the app, stage, and logical ID.
38 * @default ${app}-${stage}-${id}
39 */
40 description?: string;
41 /**
42 * The method to use for the health check. Defaults to `GET` for
43 * HTTP/HTTPS based checks and `connection_established` for TCP based
44 * health checks.
45 */
46 method?: string;
47 /**
48 * The endpoint path to conduct the health check against. Only valid for
49 * HTTP and HTTPS monitors.
50 * @default "/"
51 */
52 path?: string;
53 /**
54 * The port number to connect to for the health check. Required for TCP,
55 * UDP, and SMTP checks; HTTP/HTTPS checks only need it for non-standard
56 * ports.
57 */
58 port?: number;
59 /**
60 * The interval between each health check, in seconds. Shorter intervals
61 * improve failover time but increase origin load. The allowed range is
62 * plan-dependent.
63 * @default 60
64 */
65 interval?: number;
66 /**
67 * The timeout (in seconds) before marking the health check as failed.
68 * @default 5
69 */
70 timeout?: number;
71 /**
72 * The number of retries to attempt in case of a timeout before marking
73 * the origin as unhealthy. Retries are attempted immediately.
74 * @default 2
75 */
76 retries?: number;
77 /**
78 * To be marked healthy the monitored origin must pass this healthcheck N
79 * consecutive times.
80 * @default 0
81 */
82 consecutiveUp?: number;
83 /**
84 * To be marked unhealthy the monitored origin must fail this healthcheck
85 * N consecutive times.
86 * @default 0
87 */
88 consecutiveDown?: number;
89 /**
90 * The expected HTTP response code or code range of the health check
91 * (e.g. `"2xx"`). Only valid for HTTP and HTTPS monitors.
92 */
93 expectedCodes?: string;
94 /**
95 * A case-insensitive sub-string to look for in the response body. If not
96 * found, the origin is marked unhealthy. HTTP/HTTPS only.
97 */
98 expectedBody?: string;
99 /**
100 * Follow redirects returned by the origin. HTTP/HTTPS only.
101 * @default false
102 */
103 followRedirects?: boolean;
104 /**
105 * Do not validate the certificate when the monitor uses HTTPS.
106 * @default false
107 */
108 allowInsecure?: boolean;
109 /**
110 * The HTTP request headers to send in the health check (e.g. a `Host`
111 * header). The `User-Agent` header cannot be overridden. HTTP/HTTPS only.
112 */
113 header?: Record<string, ReadonlyArray<string>>;
114 /**
115 * Assign this monitor to emulate the specified zone while probing.
116 * HTTP/HTTPS only.
117 */
118 probeZone?: string;
119}
120
121export interface MonitorAttributes {
122 /** Cloudflare-assigned monitor identifier. */
123 monitorId: string;
124 /** The Cloudflare account the monitor belongs to. */
125 accountId: string;
126 /** Monitor description (carries the physical name when generated). */
127 description: string;
128 /** Probe protocol. */
129 type: MonitorType;
130 /** ISO8601 creation timestamp. */
131 createdOn: string | undefined;
132 /** ISO8601 last-modified timestamp. */
133 modifiedOn: string | undefined;
134}
135
136export type Monitor = Resource<
137 TypeId,
138 MonitorProps,
139 MonitorAttributes,
140 never,
141 Providers
142>;
143
144/**
145 * A Cloudflare Load Balancing monitor — an active health check (HTTP,
146 * HTTPS, TCP, ICMP, or SMTP probe) that Load Balancing pools reference to
147 * decide which origins are healthy.
148 *
149 * Monitors are account-scoped and have no name field; the `description`
150 * carries the physical name so lost state can be recovered. All properties
151 * are mutable in place.
152 *
153 * Requires the Load Balancing subscription on the account. The allowed
154 * `interval` range is plan-dependent.
155 * ### Creating a Monitor
156 * **Example:** HTTPS health check
157 * ```typescript
158 * const monitor = yield* Cloudflare.LoadBalancer.Monitor("ApiMonitor", {
159 * type: "https",
160 * path: "/health",
161 * expectedCodes: "2xx",
162 * });
163 * ```
164 *
165 * **Example:** TCP port check
166 * ```typescript
167 * const tcp = yield* Cloudflare.LoadBalancer.Monitor("DbMonitor", {
168 * type: "tcp",
169 * port: 5432,
170 * });
171 * ```
172 *
173 * ### Using with a Pool
174 * **Example:** Attach the monitor to a pool
175 * ```typescript
176 * const pool = yield* Cloudflare.LoadBalancer.Pool("ApiPool", {
177 * origins: [{ name: "origin-1", address: "203.0.113.10" }],
178 * monitor: monitor.monitorId,
179 * });
180 * ```
181 *
182 * @see https://developers.cloudflare.com/load-balancing/monitors/
183 *
184 * @resource
185 * @product Load Balancers
186 * @category Performance & Reliability
187 */
188export const Monitor = Resource<Monitor>(TypeId);
189
190/**
191 * Returns true if the given value is a Monitor resource.
192 */
193export const isMonitor = (value: unknown): value is Monitor =>
194 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
195
196export const MonitorProvider = () =>
197 Provider.succeed(Monitor, {
198 stables: ["monitorId", "accountId", "createdOn"],
199
200 // Account-scoped collection: monitors are enumerated by the account-wide
201 // listMonitors endpoint, whose result items already carry the full monitor
202 // shape — map each directly into the exact `read` Attributes shape.
203 list: Effect.fn(function* () {
204 const { accountId } = yield* yield* CloudflareEnvironment;
205 return yield* loadBalancers.listMonitors.pages({ accountId }).pipe(
206 Stream.runCollect,
207 Effect.map((chunk) =>
208 Array.from(chunk).flatMap((page) =>
209 (page.result ?? []).map((monitor) =>
210 toAttributes(monitor, accountId),
211 ),
212 ),
213 ),
214 );
215 }),
216
217 diff: Effect.fn(function* ({ output }) {
218 const { accountId } = yield* yield* CloudflareEnvironment;
219 if ((output?.accountId ?? accountId) !== accountId) {
220 return { action: "replace" } as const;
221 }
222 return undefined;
223 }),
224
225 read: Effect.fn(function* ({ id, output, olds }) {
226 const { accountId } = yield* yield* CloudflareEnvironment;
227 const acct = output?.accountId ?? accountId;
228
229 if (output?.monitorId) {
230 const observed = yield* getMonitor(acct, output.monitorId);
231 return observed ? toAttributes(observed, acct) : undefined;
232 }
233 // Cold read — recover from lost state by matching the deterministic
234 // description (our physical name). Descriptions are not unique on
235 // Cloudflare's side and carry no ownership marker, so report the
236 // match as Unowned and let the engine gate takeover behind --adopt.
237 const description = yield* createDescription(id, olds?.description);
238 const match = yield* findByDescription(acct, description);
239 if (match?.id) {
240 const observed = yield* getMonitor(acct, match.id);
241 if (observed) return Unowned(toAttributes(observed, acct));
242 }
243 return undefined;
244 }),
245
246 reconcile: Effect.fn(function* ({ id, news, output }) {
247 const { accountId } = yield* yield* CloudflareEnvironment;
248 const description = yield* createDescription(id, news.description);
249 const body = buildBody(news, description);
250
251 // 1. Observe — output.monitorId is a hint, not a guarantee.
252 const observed = output?.monitorId
253 ? yield* getMonitor(output.accountId ?? accountId, output.monitorId)
254 : undefined;
255
256 // 2. Ensure — missing (greenfield or out-of-band delete): create.
257 if (!observed?.id) {
258 const created = yield* loadBalancers.createMonitor({
259 accountId,
260 ...body,
261 });
262 return toAttributes(created, accountId);
263 }
264
265 // 3. Sync — the update endpoint is a PUT requiring the full body;
266 // diff observed against desired and skip the call on a no-op.
267 if (!monitorDirty(observed, body)) {
268 return toAttributes(observed, accountId);
269 }
270 const updated = yield* loadBalancers.updateMonitor({
271 accountId,
272 monitorId: observed.id,
273 ...body,
274 });
275 return toAttributes(updated, accountId);
276 }),
277
278 delete: Effect.fn(function* ({ output }) {
279 // A monitor still referenced by a pool cannot be deleted — the engine
280 // deletes the pool first, but eventual consistency may lag, so retry
281 // the typed MonitorInUse tag with bounded backoff.
282 yield* loadBalancers
283 .deleteMonitor({
284 accountId: output.accountId,
285 monitorId: output.monitorId,
286 })
287 .pipe(
288 Effect.retry({
289 while: (e) => e._tag === "MonitorInUse",
290 schedule: Schedule.max([
291 Schedule.exponential("1 second"),
292 Schedule.recurs(6),
293 ]),
294 }),
295 Effect.catchTag("MonitorNotFound", () => Effect.void),
296 );
297 }),
298 });
299
300type ObservedMonitor =
301 | loadBalancers.GetMonitorResponse
302 | loadBalancers.CreateMonitorResponse
303 | loadBalancers.UpdateMonitorResponse;
304
305/**
306 * Read a monitor by id, mapping "gone" (`MonitorNotFound`, HTTP 404 code
307 * 1001) to `undefined`.
308 */
309const getMonitor = (accountId: string, monitorId: string) =>
310 loadBalancers
311 .getMonitor({ accountId, monitorId })
312 .pipe(Effect.catchTag("MonitorNotFound", () => Effect.succeed(undefined)));
313
314/**
315 * Find a monitor by exact description. If several monitors carry the same
316 * description, pick the oldest for determinism.
317 */
318const findByDescription = (accountId: string, description: string) =>
319 loadBalancers.listMonitors.items({ accountId }).pipe(
320 Stream.filter((m) => m.description === description),
321 Stream.runCollect,
322 Effect.map((chunk) =>
323 Array.from(chunk)
324 .sort((a, b) => (a.createdOn ?? "").localeCompare(b.createdOn ?? ""))
325 .at(0),
326 ),
327 );
328
329const createDescription = (id: string, description: string | undefined) =>
330 Effect.gen(function* () {
331 return description ?? (yield* createPhysicalName({ id, lowercase: true }));
332 });
333
334const buildBody = (news: MonitorProps, description: string) => ({
335 description,
336 type: news.type ?? ("http" as const),
337 method: news.method,
338 path: news.path,
339 port: news.port,
340 interval: news.interval,
341 timeout: news.timeout,
342 retries: news.retries,
343 consecutiveUp: news.consecutiveUp,
344 consecutiveDown: news.consecutiveDown,
345 expectedCodes: news.expectedCodes,
346 expectedBody: news.expectedBody,
347 followRedirects: news.followRedirects,
348 allowInsecure: news.allowInsecure,
349 header:
350 news.header === undefined
351 ? undefined
352 : (Object.fromEntries(
353 Object.entries(news.header).map(([k, v]) => [k, Array.from(v)]),
354 ) as Record<string, string[]>),
355 probeZone: news.probeZone,
356});
357
358/**
359 * Compare desired (explicitly set) fields against observed cloud state.
360 * Unset desired fields defer to whatever the cloud already has.
361 */
362const monitorDirty = (
363 observed: ObservedMonitor,
364 body: ReturnType<typeof buildBody>,
365): boolean => {
366 const scalarDirty = (
367 desired: string | number | boolean | undefined,
368 actual: string | number | boolean | null | undefined,
369 ) => desired !== undefined && desired !== (actual ?? undefined);
370 return (
371 observed.description !== body.description ||
372 (observed.type ?? "http") !== body.type ||
373 scalarDirty(body.method, observed.method) ||
374 scalarDirty(body.path, observed.path) ||
375 scalarDirty(body.port, observed.port) ||
376 scalarDirty(body.interval, observed.interval) ||
377 scalarDirty(body.timeout, observed.timeout) ||
378 scalarDirty(body.retries, observed.retries) ||
379 scalarDirty(body.consecutiveUp, observed.consecutiveUp) ||
380 scalarDirty(body.consecutiveDown, observed.consecutiveDown) ||
381 scalarDirty(body.expectedCodes, observed.expectedCodes) ||
382 scalarDirty(body.expectedBody, observed.expectedBody) ||
383 scalarDirty(body.followRedirects, observed.followRedirects) ||
384 scalarDirty(body.allowInsecure, observed.allowInsecure) ||
385 scalarDirty(body.probeZone, observed.probeZone) ||
386 (body.header !== undefined &&
387 JSON.stringify(body.header) !== JSON.stringify(observed.header ?? {}))
388 );
389};
390
391const toAttributes = (
392 monitor: ObservedMonitor,
393 accountId: string,
394): MonitorAttributes => ({
395 monitorId: monitor.id ?? "",
396 accountId,
397 description: monitor.description ?? "",
398 type: (monitor.type ?? "http") as MonitorType,
399 createdOn: monitor.createdOn ?? undefined,
400 modifiedOn: monitor.modifiedOn ?? undefined,
401});