1import * as waitingRooms from "@distilled.cloud/cloudflare/waiting-rooms";
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.WaitingRoom.Settings" as const;
13type TypeId = typeof TypeId;
14
15export type SettingsProps = {
16 /**
17 * Zone whose waiting room settings are managed. Stable — changing the
18 * zone triggers a replacement (the old zone's settings are restored to
19 * the value they had before Alchemy managed them).
20 */
21 zoneId: string;
22 /**
23 * Whether to allow verified search engine crawlers to bypass all waiting
24 * rooms on this zone. Enabling the bypass requires the Waiting Room
25 * Advanced subscription. Mutable.
26 * @default false
27 */
28 searchEngineCrawlerBypass?: boolean;
29};
30
31export type SettingsAttributes = {
32 /** Zone the settings belong to. */
33 zoneId: string;
34 /** Whether verified search engine crawlers bypass all waiting rooms. */
35 searchEngineCrawlerBypass: boolean;
36 /**
37 * The value the setting had before Alchemy first managed it. Restored on
38 * destroy, so deleting the resource puts the zone back the way it was
39 * found.
40 */
41 initialSearchEngineCrawlerBypass: boolean;
42};
43
44export type Settings = Resource<
45 TypeId,
46 SettingsProps,
47 SettingsAttributes,
48 never,
49 Providers
50>;
51
52/**
53 * Zone-wide Cloudflare Waiting Room settings
54 * (`/zones/{zone_id}/waiting_rooms/settings`).
55 *
56 * The settings object is a zone singleton — it always exists with
57 * Cloudflare defaults, so this resource never creates or deletes anything
58 * physical. Reconcile PUTs the settings when the observed value differs
59 * from the desired one; destroy restores the value the zone had before
60 * Alchemy first managed it.
61 *
62 * Writes are plan-gated: on zones without a Waiting Rooms entitlement
63 * (Business/Enterprise) every PUT fails with the typed `ZoneNotEntitled`
64 * error (Cloudflare code 1034). Reads work on every plan, and a no-op
65 * reconcile (desired equals observed) skips the API call entirely.
66 * ### Managing settings
67 * **Example:** Let search engine crawlers bypass waiting rooms
68 * ```typescript
69 * yield* Cloudflare.WaitingRoom.Settings("CrawlerBypass", {
70 * zoneId: zone.zoneId,
71 * searchEngineCrawlerBypass: true,
72 * });
73 * ```
74 *
75 * **Example:** Pin the settings to their defaults
76 * ```typescript
77 * yield* Cloudflare.WaitingRoom.Settings("Defaults", {
78 * zoneId: zone.zoneId,
79 * searchEngineCrawlerBypass: false,
80 * });
81 * ```
82 *
83 * @see https://developers.cloudflare.com/waiting-room/
84 *
85 * @resource
86 * @product Waiting Rooms
87 * @category Performance & Reliability
88 */
89export const Settings = Resource<Settings>(TypeId);
90
91/**
92 * Returns true if the given value is a Settings resource.
93 */
94export const isSettings = (value: unknown): value is Settings =>
95 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
96
97export const SettingsProvider = () =>
98 Provider.succeed(Settings, {
99 nuke: { singleton: true },
100 stables: ["zoneId", "initialSearchEngineCrawlerBypass"],
101
102 list: Effect.fn(function* () {
103 const { accountId } = yield* yield* CloudflareEnvironment;
104 // No account-wide API for this zone singleton — enumerate every
105 // zone in the account and read its settings (every zone has one).
106 const allZones = yield* listAllZones(accountId);
107 const rows = yield* Effect.forEach(
108 allZones.map((zone) => zone.id),
109 (zoneId) =>
110 waitingRooms.getSetting({ zoneId }).pipe(
111 // A freshly-minted scoped token propagates eventually-
112 // consistently across Cloudflare's edge — ride out transient
113 // 403 blips before giving up on a zone. The backoff is CAPPED at
114 // 5s and bounded to ~8 attempts (~40s): an uncapped
115 // `Schedule.exponential` reaches a 64s single delay by the 8th
116 // retry (~128s total) which, fanned across every zone, blows the
117 // test timeout when a zone is persistently 403.
118 Effect.retry({
119 while: (e) => e._tag === "Forbidden",
120 schedule: Schedule.max([
121 Schedule.min([
122 Schedule.exponential("500 millis"),
123 Schedule.spaced("5 seconds"),
124 ]),
125 Schedule.recurs(8),
126 ]),
127 }),
128 Effect.map((observed) =>
129 toAttributes(
130 zoneId,
131 observed.searchEngineCrawlerBypass,
132 observed.searchEngineCrawlerBypass,
133 ),
134 ),
135 // Plan-gated or partial zones reject the route; a zone the token
136 // still can't read (persistent 403) isn't ours to enumerate —
137 // skip both rather than failing the whole listing.
138 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
139 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
140 ),
141 { concurrency: 10 },
142 );
143 return rows.filter((row): row is SettingsAttributes => row !== undefined);
144 }),
145
146 diff: Effect.fn(function* ({ olds = {}, news, output }) {
147 const o = olds as SettingsProps;
148 const n = news as SettingsProps;
149 // zoneId is Input<string>; compare only once both sides are concrete.
150 const oldZoneId =
151 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
152 if (
153 oldZoneId !== undefined &&
154 typeof n.zoneId === "string" &&
155 oldZoneId !== n.zoneId
156 ) {
157 return { action: "replace" } as const;
158 }
159 return undefined;
160 }),
161
162 read: Effect.fn(function* ({ output, olds }) {
163 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
164 if (!zoneId) return undefined;
165 const observed = yield* waitingRooms.getSetting({ zoneId }).pipe(
166 // Zone deleted out-of-band — the settings are gone with it.
167 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
168 );
169 if (observed === undefined) return undefined;
170 // The settings singleton always exists with a Cloudflare default —
171 // there is nothing to "own", so a cold read adopts freely. The
172 // observed value at adoption time becomes the initial value restored
173 // on destroy.
174 const initial =
175 output !== undefined
176 ? output.initialSearchEngineCrawlerBypass
177 : observed.searchEngineCrawlerBypass;
178 return toAttributes(zoneId, observed.searchEngineCrawlerBypass, initial);
179 }),
180
181 reconcile: Effect.fn(function* ({ news, output }) {
182 // Inputs have been resolved to concrete strings by Plan.
183 const zoneId = news.zoneId as string;
184
185 // 1. Observe — the settings singleton always exists.
186 const observed = yield* waitingRooms.getSetting({ zoneId });
187
188 // 2. Capture — the pre-management value, restored on destroy.
189 const initial =
190 output !== undefined
191 ? output.initialSearchEngineCrawlerBypass
192 : observed.searchEngineCrawlerBypass;
193
194 // 3. Sync — PUT only when the observed value differs.
195 const desired = news.searchEngineCrawlerBypass ?? false;
196 if (observed.searchEngineCrawlerBypass === desired) {
197 return toAttributes(
198 zoneId,
199 observed.searchEngineCrawlerBypass,
200 initial,
201 );
202 }
203 const updated = yield* waitingRooms.putSetting({
204 zoneId,
205 searchEngineCrawlerBypass: desired,
206 });
207 return toAttributes(zoneId, updated.searchEngineCrawlerBypass, initial);
208 }),
209
210 delete: Effect.fn(function* ({ output }) {
211 const { zoneId, initialSearchEngineCrawlerBypass } = output;
212 // Observe — if the zone itself is gone, so are the settings.
213 const observed = yield* waitingRooms
214 .getSetting({ zoneId })
215 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
216 if (observed === undefined) return;
217 // Restore the pre-management value; skip the call when it already
218 // matches (idempotent re-delete after a crashed run).
219 if (
220 observed.searchEngineCrawlerBypass === initialSearchEngineCrawlerBypass
221 ) {
222 return;
223 }
224 yield* waitingRooms
225 .putSetting({
226 zoneId,
227 searchEngineCrawlerBypass: initialSearchEngineCrawlerBypass,
228 })
229 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
230 }),
231 });
232
233const toAttributes = (
234 zoneId: string,
235 searchEngineCrawlerBypass: boolean,
236 initialSearchEngineCrawlerBypass: boolean,
237): SettingsAttributes => ({
238 zoneId,
239 searchEngineCrawlerBypass,
240 initialSearchEngineCrawlerBypass,
241});