1import * as loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
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 { isResolved } from "../../Diff.ts";
8import type { Input } from "../../Input.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.LoadBalancer.LoadBalancer" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Steering policy for a Load Balancer.
20 */
21export type SteeringPolicy =
22 | "off"
23 | "geo"
24 | "random"
25 | "dynamic_latency"
26 | "proximity"
27 | "least_outstanding_requests"
28 | "least_connections"
29 | "";
30
31/**
32 * Session affinity mode for a Load Balancer.
33 */
34export type SessionAffinity = "none" | "cookie" | "ip_cookie" | "header";
35
36/**
37 * Session affinity attributes for a Load Balancer.
38 */
39export interface SessionAffinityAttributes {
40 /** Seconds to drain an origin of affine sessions before removal. */
41 drainDuration?: number;
42 /** Headers to base header-mode session affinity on. */
43 headers?: ReadonlyArray<string>;
44 /** Whether all configured headers must match. */
45 requireAllHeaders?: boolean;
46 /** SameSite attribute of the affinity cookie. */
47 samesite?: "Auto" | "Lax" | "None" | "Strict";
48 /** Secure attribute of the affinity cookie. */
49 secure?: "Auto" | "Always" | "Never";
50 /** Failover behavior when an affine origin becomes unavailable. */
51 zeroDowntimeFailover?: "none" | "temporary" | "sticky";
52}
53
54/**
55 * Location strategy for non-proxied (DNS-steered) Load Balancers.
56 */
57export interface LocationStrategy {
58 /** Resolution mode used to determine the client's location. */
59 mode?: "pop" | "resolver_ip";
60 /** When to prefer the EDNS Client Subnet over the resolver IP. */
61 preferEcs?: "always" | "never" | "proximity" | "geo";
62}
63
64export interface Props {
65 /**
66 * Zone the load balancer lives in. Stable — changing the zone triggers
67 * replacement.
68 */
69 zoneId: string;
70 /**
71 * The DNS hostname to associate with the Load Balancer (e.g.
72 * `lb.example.com`). If this hostname already exists as a DNS record,
73 * the Load Balancer takes precedence. Mutable in place.
74 */
75 name: string;
76 /**
77 * Pool IDs ordered by their failover priority. Used by default, or when
78 * region/country/PoP pools are not configured for a request.
79 */
80 defaultPools: ReadonlyArray<string>;
81 /**
82 * The pool ID to use when all other pools are detected as unhealthy.
83 */
84 fallbackPool: string;
85 /**
86 * Object description.
87 */
88 description?: string;
89 /**
90 * Whether the hostname is gray clouded (false) or orange clouded /
91 * proxied through Cloudflare (true).
92 * @default false
93 */
94 proxied?: boolean;
95 /**
96 * TTL (seconds) of the DNS entry for the IP address returned by this
97 * load balancer. Only applies to unproxied load balancers — the API
98 * rejects it when `proxied: true`.
99 * @default 30
100 */
101 ttl?: number;
102 /**
103 * Steering policy for this load balancer.
104 * @default ""
105 */
106 steeringPolicy?: SteeringPolicy;
107 /**
108 * Type of session affinity to use.
109 * @default "none"
110 */
111 sessionAffinity?: SessionAffinity;
112 /**
113 * Time, in seconds, until a client's session expires after being
114 * created.
115 * @default 82800
116 */
117 sessionAffinityTtl?: number;
118 /**
119 * Attributes configuring session affinity behavior.
120 */
121 sessionAffinityAttributes?: SessionAffinityAttributes;
122 /**
123 * Routing modifications in response to dynamic conditions (e.g.
124 * zero-downtime failover between health probes).
125 */
126 adaptiveRouting?: { failoverAcrossPools?: boolean };
127 /**
128 * Location-based steering behavior for non-proxied requests.
129 */
130 locationStrategy?: LocationStrategy;
131 /**
132 * Pool weights for `random` / `least_outstanding_requests` /
133 * `least_connections` steering.
134 */
135 randomSteering?: {
136 defaultWeight?: number;
137 poolWeights?: Record<string, number>;
138 };
139 /**
140 * Region code → ordered pool IDs for that region. Regions not defined
141 * fall back to `defaultPools`.
142 */
143 regionPools?: Record<string, ReadonlyArray<string>>;
144 /**
145 * Country code → ordered pool IDs for that country. Countries not
146 * defined fall back to the corresponding region pool mapping.
147 */
148 countryPools?: Record<string, ReadonlyArray<string>>;
149 /**
150 * Enterprise only — Cloudflare PoP identifier → ordered pool IDs for
151 * that PoP.
152 */
153 popPools?: Record<string, ReadonlyArray<string>>;
154}
155
156export interface Attributes {
157 /** Cloudflare-assigned load balancer identifier. */
158 loadBalancerId: string;
159 /** Zone that owns the load balancer. */
160 zoneId: string;
161 /** DNS hostname of the load balancer. */
162 name: string;
163 /** Whether the load balancer is enabled. */
164 enabled: boolean;
165 /** Whether the hostname is proxied (orange clouded). */
166 proxied: boolean;
167 /** Resolved steering policy. */
168 steeringPolicy: string;
169 /** Resolved default pool ids. */
170 defaultPools: ReadonlyArray<string>;
171 /** Resolved fallback pool id. */
172 fallbackPool: string;
173 /** ISO8601 creation timestamp. */
174 createdOn: string | undefined;
175 /** ISO8601 last-modified timestamp. */
176 modifiedOn: string | undefined;
177}
178
179export type LoadBalancer = Resource<
180 TypeId,
181 Props,
182 Attributes,
183 never,
184 Providers
185>;
186
187/**
188 * A Cloudflare Load Balancer — a zone-level DNS hostname that distributes
189 * traffic across {@link Pool}s with health-based failover,
190 * geo/latency steering, and session affinity.
191 *
192 * Requires the Load Balancing subscription to be enabled for the zone;
193 * without it, creation fails with the typed `LoadBalancingNotEnabledForZone`
194 * error.
195 * ### Creating a Load Balancer
196 * **Example:** DNS-only (unproxied) load balancer
197 * ```typescript
198 * const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("ApiLb", {
199 * zoneId: zone.zoneId,
200 * name: "api.example.com",
201 * defaultPools: [pool.poolId],
202 * fallbackPool: pool.poolId,
203 * proxied: false,
204 * ttl: 30,
205 * });
206 * ```
207 *
208 * **Example:** Proxied load balancer with steering and affinity
209 * ```typescript
210 * const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("AppLb", {
211 * zoneId: zone.zoneId,
212 * name: "app.example.com",
213 * defaultPools: [primary.poolId, secondary.poolId],
214 * fallbackPool: secondary.poolId,
215 * proxied: true,
216 * steeringPolicy: "random",
217 * sessionAffinity: "cookie",
218 * });
219 * ```
220 *
221 * ### Geo steering
222 * **Example:** Region pools
223 * ```typescript
224 * yield* Cloudflare.LoadBalancer.LoadBalancer("GeoLb", {
225 * zoneId: zone.zoneId,
226 * name: "geo.example.com",
227 * defaultPools: [us.poolId],
228 * fallbackPool: us.poolId,
229 * steeringPolicy: "geo",
230 * regionPools: {
231 * WEU: [eu.poolId],
232 * ENAM: [us.poolId],
233 * },
234 * });
235 * ```
236 *
237 * @see https://developers.cloudflare.com/load-balancing/
238 *
239 * @resource
240 * @product Load Balancers
241 * @category Performance & Reliability
242 */
243export const LoadBalancer = Resource<LoadBalancer>(TypeId, {
244 aliases: ["Cloudflare.LoadBalancer"],
245});
246
247/**
248 * Returns true if the given value is a LoadBalancer resource.
249 */
250export const isLoadBalancer = (value: unknown): value is LoadBalancer =>
251 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
252
253export const LoadBalancerProvider = () =>
254 Provider.succeed(LoadBalancer, {
255 stables: ["loadBalancerId", "zoneId", "createdOn"],
256
257 diff: Effect.fn(function* ({ olds, news }) {
258 if (!isResolved(news)) return undefined;
259 // zoneId is Input<string>; by diff time both sides are concrete
260 // strings when statically known.
261 if (
262 typeof olds.zoneId === "string" &&
263 typeof news.zoneId === "string" &&
264 olds.zoneId !== news.zoneId
265 ) {
266 return { action: "replace" } as const;
267 }
268 }),
269
270 read: Effect.fn(function* ({ output, olds }) {
271 if (output?.loadBalancerId) {
272 const observed = yield* getLoadBalancer(
273 output.zoneId,
274 output.loadBalancerId,
275 );
276 return observed ? toAttributes(observed, output.zoneId) : undefined;
277 }
278 // Cold read — a load balancer's hostname is unique within its zone.
279 // Load balancers carry no ownership marker, so report the match as
280 // Unowned and let the engine gate takeover behind --adopt.
281 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
282 const name = output?.name ?? olds?.name;
283 if (zoneId && name) {
284 const match = yield* findByName(zoneId, name);
285 if (match?.id) {
286 const observed = yield* getLoadBalancer(zoneId, match.id);
287 if (observed) return Unowned(toAttributes(observed, zoneId));
288 }
289 }
290 return undefined;
291 }),
292
293 reconcile: Effect.fn(function* ({ news, output }) {
294 // Inputs have been resolved to concrete strings by Plan.
295 const zoneId = news.zoneId as string;
296 const body = buildBody(news);
297
298 // 1. Observe — output.loadBalancerId is a cache hint; a 404 falls
299 // through to "missing" and we recreate.
300 const observed = output?.loadBalancerId
301 ? yield* getLoadBalancer(output.zoneId ?? zoneId, output.loadBalancerId)
302 : undefined;
303
304 // 2. Ensure — missing: create with the full desired body.
305 if (!observed?.id) {
306 const created = yield* loadBalancers.createLoadBalancer({
307 zoneId,
308 ...body,
309 });
310 return toAttributes(created, zoneId);
311 }
312
313 // 3. Sync — the update endpoint is a PUT requiring the full body;
314 // diff observed against desired and skip the call on a no-op.
315 if (!loadBalancerDirty(observed, body)) {
316 return toAttributes(observed, zoneId);
317 }
318 const updated = yield* loadBalancers.updateLoadBalancer({
319 zoneId,
320 loadBalancerId: observed.id,
321 ...body,
322 });
323 return toAttributes(updated, zoneId);
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 yield* loadBalancers
328 .deleteLoadBalancer({
329 zoneId: output.zoneId,
330 loadBalancerId: output.loadBalancerId,
331 })
332 .pipe(Effect.catchTag("LoadBalancerNotFound", () => Effect.void));
333 }),
334
335 // Load balancers are zone-scoped; enumerate every zone in the account and
336 // exhaustively paginate each zone's load balancers, hydrating each into the
337 // same Attributes shape `read` returns. Zones without the Load Balancing
338 // subscription reject the route (Forbidden) and are skipped.
339 list: Effect.fn(function* () {
340 const { accountId } = yield* yield* CloudflareEnvironment;
341 const zones = yield* listAllZones(accountId);
342 const rows = yield* Effect.forEach(
343 zones,
344 (zone) =>
345 loadBalancers.listLoadBalancers.pages({ zoneId: zone.id }).pipe(
346 Stream.runCollect,
347 Effect.map((chunk) =>
348 Array.from(chunk).flatMap((page) =>
349 (page.result ?? []).map((lb) => toAttributes(lb, zone.id)),
350 ),
351 ),
352 ),
353 { concurrency: 10 },
354 );
355 return rows.flat();
356 }),
357 });
358
359type ObservedLoadBalancer =
360 | loadBalancers.GetLoadBalancerResponse
361 | loadBalancers.CreateLoadBalancerResponse
362 | loadBalancers.UpdateLoadBalancerResponse
363 | loadBalancers.ListLoadBalancersResponse["result"][number];
364
365/**
366 * Read a load balancer by id, mapping "gone" (`LoadBalancerNotFound`,
367 * HTTP 404 code 1001) to `undefined`.
368 */
369const getLoadBalancer = (zoneId: string, loadBalancerId: string) =>
370 loadBalancers
371 .getLoadBalancer({ zoneId, loadBalancerId })
372 .pipe(
373 Effect.catchTag("LoadBalancerNotFound", () => Effect.succeed(undefined)),
374 );
375
376/**
377 * Find a load balancer by exact hostname within a zone.
378 */
379const findByName = (zoneId: string, name: string) =>
380 loadBalancers.listLoadBalancers.items({ zoneId }).pipe(
381 Stream.filter((lb) => lb.name === name),
382 Stream.runCollect,
383 Effect.map((chunk) => Array.from(chunk).at(0)),
384 );
385
386const resolvePools = (
387 pools: Record<string, ReadonlyArray<Input<string>>> | undefined,
388): Record<string, string[]> | undefined =>
389 pools === undefined
390 ? undefined
391 : Object.fromEntries(
392 Object.entries(pools).map(([k, v]) => [k, Array.from(v as string[])]),
393 );
394
395const buildBody = (news: Props) => ({
396 name: news.name,
397 defaultPools: news.defaultPools as string[],
398 fallbackPool: news.fallbackPool as string,
399 description: news.description,
400 proxied: news.proxied,
401 ttl: news.proxied === true ? undefined : news.ttl,
402 steeringPolicy: news.steeringPolicy,
403 sessionAffinity: news.sessionAffinity,
404 sessionAffinityTtl: news.sessionAffinityTtl,
405 sessionAffinityAttributes:
406 news.sessionAffinityAttributes === undefined
407 ? undefined
408 : {
409 ...news.sessionAffinityAttributes,
410 headers:
411 news.sessionAffinityAttributes.headers === undefined
412 ? undefined
413 : Array.from(news.sessionAffinityAttributes.headers),
414 },
415 adaptiveRouting: news.adaptiveRouting,
416 locationStrategy: news.locationStrategy,
417 randomSteering: news.randomSteering,
418 regionPools: resolvePools(news.regionPools),
419 countryPools: resolvePools(news.countryPools),
420 popPools: resolvePools(news.popPools),
421});
422
423/**
424 * Compare desired (explicitly set) fields against observed cloud state.
425 * Unset desired fields defer to whatever the cloud already has.
426 */
427const loadBalancerDirty = (
428 observed: ObservedLoadBalancer,
429 body: ReturnType<typeof buildBody>,
430): boolean => {
431 const scalarDirty = (
432 desired: string | number | boolean | undefined,
433 actual: string | number | boolean | null | undefined,
434 ) => desired !== undefined && desired !== (actual ?? undefined);
435 const structDirty = (desired: unknown, actual: unknown) =>
436 desired !== undefined &&
437 JSON.stringify(desired) !== JSON.stringify(actual ?? {});
438
439 return (
440 (observed.name ?? "") !== body.name ||
441 JSON.stringify(observed.defaultPools ?? []) !==
442 JSON.stringify(body.defaultPools) ||
443 (observed.fallbackPool ?? "") !== body.fallbackPool ||
444 scalarDirty(body.description, observed.description) ||
445 scalarDirty(body.proxied, observed.proxied) ||
446 scalarDirty(body.ttl, observed.ttl) ||
447 scalarDirty(body.steeringPolicy, observed.steeringPolicy) ||
448 scalarDirty(body.sessionAffinity, observed.sessionAffinity) ||
449 scalarDirty(body.sessionAffinityTtl, observed.sessionAffinityTtl) ||
450 structDirty(
451 body.sessionAffinityAttributes,
452 observed.sessionAffinityAttributes,
453 ) ||
454 structDirty(body.adaptiveRouting, observed.adaptiveRouting) ||
455 structDirty(body.locationStrategy, observed.locationStrategy) ||
456 structDirty(body.randomSteering, observed.randomSteering) ||
457 structDirty(body.regionPools, observed.regionPools) ||
458 structDirty(body.countryPools, observed.countryPools) ||
459 structDirty(body.popPools, observed.popPools)
460 );
461};
462
463const toAttributes = (
464 lb: ObservedLoadBalancer,
465 zoneId: string,
466): Attributes => ({
467 loadBalancerId: lb.id ?? "",
468 zoneId,
469 name: lb.name ?? "",
470 enabled: lb.enabled ?? true,
471 proxied: lb.proxied ?? false,
472 steeringPolicy: lb.steeringPolicy ?? "",
473 defaultPools: [...(lb.defaultPools ?? [])],
474 fallbackPool: lb.fallbackPool ?? "",
475 createdOn: lb.createdOn ?? undefined,
476 modifiedOn: lb.modifiedOn ?? undefined,
477});