1import * as waitingRooms from "@distilled.cloud/cloudflare/waiting-rooms";
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 { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.WaitingRoom.WaitingRoom" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Queueing method used by a waiting room. Changing this from the default
19 * `fifo` requires the Waiting Room Advanced subscription.
20 */
21export type QueueingMethod = "fifo" | "random" | "passthrough" | "reject";
22
23/**
24 * HTTP status code returned to a user while in the queue.
25 */
26export type QueueingStatusCode = 200 | 202 | 429;
27
28/**
29 * Which Turnstile widget type the waiting room uses for detecting bot
30 * traffic.
31 */
32export type TurnstileMode =
33 | "off"
34 | "invisible"
35 | "visible_non_interactive"
36 | "visible_managed";
37
38/**
39 * What to do when Turnstile detects a bot: `log` only records it in
40 * analytics; `infinite_queue` sends the bot to a queue that never lets it
41 * through.
42 */
43export type TurnstileAction = "log" | "infinite_queue";
44
45/**
46 * Cookie attributes for the waiting room cookie (`__cf_waitingroom`).
47 */
48export interface CookieAttributes {
49 /**
50 * SameSite attribute of the waiting room cookie.
51 * @default "auto"
52 */
53 samesite?: "auto" | "lax" | "none" | "strict";
54 /**
55 * Secure attribute of the waiting room cookie.
56 * @default "auto"
57 */
58 secure?: "auto" | "always" | "never";
59}
60
61/**
62 * An additional hostname + path combination the waiting room is applied to.
63 * Only available with the Waiting Room Advanced subscription.
64 */
65export interface Route {
66 /**
67 * Hostname (no scheme, no wildcards).
68 */
69 host?: string;
70 /**
71 * Path within the host. There is an implied wildcard at the end.
72 * @default "/"
73 */
74 path?: string;
75}
76
77export interface Props {
78 /**
79 * Zone the waiting room belongs to. Stable — changing the zone triggers
80 * a replacement.
81 */
82 zoneId: string;
83 /**
84 * A unique name to identify the waiting room. Only alphanumeric
85 * characters, hyphens, and underscores are allowed. If omitted, a unique
86 * name is generated from the app, stage, and logical ID. Mutable.
87 * @default ${app}-${stage}-${id}
88 */
89 name?: string;
90 /**
91 * The host name to which the waiting room will be applied (no
92 * wildcards). Do not include the scheme. The host and path combination
93 * must be unique per zone. Mutable.
94 */
95 host: string;
96 /**
97 * The path within the host to enable the waiting room on, including all
98 * subpaths. Mutable.
99 * @default "/"
100 */
101 path?: string;
102 /**
103 * Total number of active user sessions on the route at a point in time
104 * (minimum 200). Mutable.
105 */
106 totalActiveUsers: number;
107 /**
108 * Number of new users that will be let into the route every minute
109 * (minimum 200). Mutable.
110 */
111 newUsersPerMinute: number;
112 /**
113 * A note with more details about the waiting room. Mutable.
114 * @default ""
115 */
116 description?: string;
117 /**
118 * Lifetime of a cookie (in minutes, 1–30) set for users who get access
119 * to the route. Mutable.
120 * @default 5
121 */
122 sessionDuration?: number;
123 /**
124 * Disables automatic renewal of session cookies (Waiting Room Advanced
125 * only). Mutable.
126 * @default false
127 */
128 disableSessionRenewal?: boolean;
129 /**
130 * If `true`, all traffic to the route is sent to the waiting room.
131 * Mutable.
132 * @default false
133 */
134 queueAll?: boolean;
135 /**
136 * Queueing method. Non-`fifo` methods require the Waiting Room Advanced
137 * subscription. Mutable.
138 * @default "fifo"
139 */
140 queueingMethod?: QueueingMethod;
141 /**
142 * HTTP status code returned to a user while in the queue. Mutable.
143 * @default "200"
144 */
145 queueingStatusCode?: QueueingStatusCode;
146 /**
147 * Suspends the waiting room — traffic flows straight to the route.
148 * Mutable.
149 * @default false
150 */
151 suspended?: boolean;
152 /**
153 * If `true`, requests with `Accept: application/json` receive a JSON
154 * response describing the queue (Waiting Room Advanced only). Mutable.
155 * @default false
156 */
157 jsonResponseEnabled?: boolean;
158 /**
159 * Custom HTML template rendered at the edge instead of the default
160 * waiting room page (Waiting Room Advanced only). Mutable.
161 */
162 customPageHtml?: string;
163 /**
164 * Language of the default page template. Mutable.
165 * @default "en-US"
166 */
167 defaultTemplateLanguage?: string;
168 /**
169 * Appends `_` + this suffix to the waiting room cookie name. Mutable.
170 */
171 cookieSuffix?: string;
172 /**
173 * Cookie attributes for the waiting room cookie. Mutable.
174 */
175 cookieAttributes?: CookieAttributes;
176 /**
177 * Additional hostname/path combinations the waiting room applies to
178 * (Waiting Room Advanced only). Mutable.
179 */
180 additionalRoutes?: Route[];
181 /**
182 * Enabled origin commands (currently only `revoke`). Mutable.
183 * @default []
184 */
185 enabledOriginCommands?: "revoke"[];
186 /**
187 * Turnstile widget type used for detecting bot traffic. Mutable.
188 * @default "off"
189 */
190 turnstileMode?: TurnstileMode;
191 /**
192 * Action taken when Turnstile detects a bot. Mutable.
193 * @default "log"
194 */
195 turnstileAction?: TurnstileAction;
196}
197
198export interface Attributes {
199 /** Cloudflare-assigned identifier of the waiting room. */
200 waitingRoomId: string;
201 /** Zone the waiting room belongs to. */
202 zoneId: string;
203 /** The waiting room's unique name. */
204 name: string;
205 /** Host the waiting room is applied to. */
206 host: string;
207 /** Path within the host the waiting room is enabled on. */
208 path: string;
209 /** Total number of active user sessions allowed on the route. */
210 totalActiveUsers: number;
211 /** Number of new users let into the route per minute. */
212 newUsersPerMinute: number;
213 /** Note describing the waiting room. */
214 description: string;
215 /** Session cookie lifetime in minutes. */
216 sessionDuration: number;
217 /** Whether all traffic is sent to the waiting room. */
218 queueAll: boolean;
219 /** Queueing method in effect. */
220 queueingMethod: QueueingMethod;
221 /** HTTP status code returned to queued users. */
222 queueingStatusCode: QueueingStatusCode;
223 /** Whether the waiting room is suspended. */
224 suspended: boolean;
225 /** ISO8601 creation timestamp. */
226 createdOn: string | undefined;
227 /** ISO8601 last-modified timestamp. */
228 modifiedOn: string | undefined;
229}
230
231export type WaitingRoom = Resource<TypeId, Props, Attributes, never, Providers>;
232
233/**
234 * A Cloudflare Waiting Room — places visitors in a virtual queue when
235 * traffic to a host + path exceeds the configured thresholds, protecting
236 * the origin from overload.
237 *
238 * Waiting Rooms require a Business or Enterprise zone plan; on unentitled
239 * zones every write fails with the typed `ZoneNotEntitled` error
240 * (Cloudflare code 1034). Several props (`additionalRoutes`,
241 * `customPageHtml`, non-`fifo` `queueingMethod`, `disableSessionRenewal`,
242 * `jsonResponseEnabled`) additionally require the Waiting Room Advanced
243 * subscription.
244 *
245 * Everything except the zone is mutable in place via a full-body PUT.
246 * Waiting rooms carry no ownership markers, so when state is lost `read`
247 * matches by name and reports the room as `Unowned` — the engine refuses
248 * to take it over unless `--adopt` (or `adopt(true)`) is set.
249 * ### Creating a Waiting Room
250 * **Example:** Basic waiting room on a host
251 * ```typescript
252 * const room = yield* Cloudflare.WaitingRoom.WaitingRoom("checkout", {
253 * zoneId: zone.zoneId,
254 * host: "shop.example.com",
255 * path: "/checkout",
256 * totalActiveUsers: 200,
257 * newUsersPerMinute: 200,
258 * });
259 * ```
260 *
261 * **Example:** Queue all traffic during an incident
262 * ```typescript
263 * yield* Cloudflare.WaitingRoom.WaitingRoom("incident-gate", {
264 * zoneId: zone.zoneId,
265 * host: "example.com",
266 * totalActiveUsers: 500,
267 * newUsersPerMinute: 200,
268 * queueAll: true,
269 * queueingStatusCode: 429,
270 * });
271 * ```
272 *
273 * ### Customizing behavior
274 * **Example:** Short sessions with a custom cookie suffix
275 * ```typescript
276 * yield* Cloudflare.WaitingRoom.WaitingRoom("flash-sale", {
277 * zoneId: zone.zoneId,
278 * host: "example.com",
279 * path: "/sale",
280 * totalActiveUsers: 1000,
281 * newUsersPerMinute: 500,
282 * sessionDuration: 1,
283 * cookieSuffix: "sale",
284 * defaultTemplateLanguage: "de-DE",
285 * });
286 * ```
287 *
288 * @see https://developers.cloudflare.com/waiting-room/
289 *
290 * @resource
291 * @product Waiting Rooms
292 * @category Performance & Reliability
293 */
294export const WaitingRoom = Resource<WaitingRoom>(TypeId, {
295 aliases: ["Cloudflare.WaitingRoom"],
296});
297
298/**
299 * Returns true if the given value is a WaitingRoom resource.
300 */
301export const isWaitingRoom = (value: unknown): value is WaitingRoom =>
302 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
303
304export const WaitingRoomProvider = () =>
305 Provider.succeed(WaitingRoom, {
306 stables: ["waitingRoomId", "zoneId", "createdOn"],
307
308 diff: Effect.fn(function* ({ olds = {}, news, output }) {
309 const o = olds as Props;
310 const n = news as Props;
311 // zoneId is Input<string>; compare only once both sides are concrete.
312 const oldZoneId =
313 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
314 if (
315 oldZoneId !== undefined &&
316 typeof n.zoneId === "string" &&
317 oldZoneId !== n.zoneId
318 ) {
319 return { action: "replace" } as const;
320 }
321 return undefined;
322 }),
323
324 read: Effect.fn(function* ({ id, output, olds }) {
325 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
326 if (!zoneId) return undefined;
327
328 // Owned path: refresh by our persisted waiting room id.
329 if (output?.waitingRoomId) {
330 const observed = yield* getRoom(zoneId, output.waitingRoomId);
331 if (observed) return toAttributes(observed, zoneId);
332 }
333
334 // Cold read — recover from lost state by matching the deterministic
335 // physical name. Waiting rooms carry no ownership markers, so report
336 // the match as `Unowned` and let the adopt policy gate takeover.
337 const name = yield* createRoomName(id, olds?.name);
338 const match = yield* findByName(zoneId, name);
339 if (match) return Unowned(toAttributes(match, zoneId));
340 return undefined;
341 }),
342
343 reconcile: Effect.fn(function* ({ id, news, output }) {
344 // Inputs have been resolved to concrete strings by Plan.
345 const zoneId = news.zoneId as string;
346 const name = yield* createRoomName(id, news.name);
347
348 // 1. Observe — the id cached on `output` is a hint, not a guarantee:
349 // a missing room falls through to the name scan and then to create.
350 let observed = output?.waitingRoomId
351 ? yield* getRoom(zoneId, output.waitingRoomId)
352 : undefined;
353 if (!observed) {
354 observed = yield* findByName(zoneId, name);
355 }
356
357 // 2. Ensure — create with the full desired body when missing.
358 if (!observed) {
359 const created = yield* waitingRooms.createWaitingRoom({
360 zoneId,
361 name,
362 ...desiredBody(news),
363 });
364 return toAttributes(created, zoneId);
365 }
366
367 // 3. Sync — the update API is a full-body PUT; diff observed cloud
368 // state against the desired body and skip the call on a no-op.
369 const desired = desiredBody(news);
370 if (!isDirty(observed, name, desired)) {
371 return toAttributes(observed, zoneId);
372 }
373 const updated = yield* waitingRooms.updateWaitingRoom({
374 zoneId,
375 waitingRoomId: observed.id as string,
376 name,
377 ...desired,
378 });
379 return toAttributes(updated, zoneId);
380 }),
381
382 delete: Effect.fn(function* ({ output }) {
383 yield* waitingRooms
384 .deleteWaitingRoom({
385 zoneId: output.zoneId,
386 waitingRoomId: output.waitingRoomId,
387 })
388 .pipe(
389 // Already gone (code 1001) — deletion is idempotent.
390 Effect.catchTag("WaitingRoomNotFound", () => Effect.void),
391 // Zone deleted out-of-band — the room is gone with it.
392 Effect.catchTag("InvalidRoute", () => Effect.void),
393 );
394 }),
395
396 list: Effect.fn(function* () {
397 const { accountId } = yield* yield* CloudflareEnvironment;
398 // Waiting rooms live inside a zone with no account-wide enumeration
399 // API — fan out across every zone and list rooms per zone, then
400 // exhaustively paginate each.
401 const zones = yield* listAllZones(accountId);
402 const rows = yield* Effect.forEach(
403 zones,
404 (zone) =>
405 waitingRooms.listWaitingRoomsForZone.pages({ zoneId: zone.id }).pipe(
406 Stream.runCollect,
407 Effect.map((chunk) =>
408 Array.from(chunk).flatMap((page) =>
409 (page.result ?? []).map((room) => toAttributes(room, zone.id)),
410 ),
411 ),
412 // Plan-gated / partial-permission zones reject the route, and a
413 // zone deleted out-of-band has no rooms; skip both.
414 Effect.catchTag("Forbidden", () =>
415 Effect.succeed([] as Attributes[]),
416 ),
417 Effect.catchTag("InvalidRoute", () =>
418 Effect.succeed([] as Attributes[]),
419 ),
420 ),
421 { concurrency: 10 },
422 );
423 return rows.flat();
424 }),
425 });
426
427type ObservedRoom = waitingRooms.GetWaitingRoomResponse;
428
429/**
430 * Read a waiting room by id, mapping "gone" (`WaitingRoomNotFound`,
431 * Cloudflare code 1001) and a deleted zone (`InvalidRoute`, code 7003) to
432 * `undefined`.
433 */
434const getRoom = (zoneId: string, waitingRoomId: string) =>
435 waitingRooms.getWaitingRoom({ zoneId, waitingRoomId }).pipe(
436 Effect.map((room): ObservedRoom | undefined => room),
437 Effect.catchTag("WaitingRoomNotFound", () => Effect.succeed(undefined)),
438 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
439 );
440
441/**
442 * Find a waiting room by exact name. Names are unique per zone, so at most
443 * one room can match.
444 */
445const findByName = (zoneId: string, name: string) =>
446 waitingRooms.listWaitingRoomsForZone.items({ zoneId }).pipe(
447 Stream.runCollect,
448 Effect.map((chunk) =>
449 Array.from(chunk).find(
450 (room): room is ObservedRoom => room.name === name,
451 ),
452 ),
453 // Zone deleted out-of-band — nothing to find.
454 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
455 );
456
457/**
458 * Waiting room names only allow alphanumerics, hyphens, and underscores.
459 */
460const createRoomName = (id: string, name: string | undefined) =>
461 Effect.gen(function* () {
462 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
463 });
464
465/**
466 * The full desired body sent on create and PUT update. Optional props are
467 * omitted when unspecified so Cloudflare applies (or resets to) its
468 * defaults — sending Advanced-only fields explicitly would fail on
469 * non-Advanced plans.
470 */
471const desiredBody = (news: Props) => ({
472 host: news.host,
473 totalActiveUsers: news.totalActiveUsers,
474 newUsersPerMinute: news.newUsersPerMinute,
475 path: news.path,
476 description: news.description,
477 sessionDuration: news.sessionDuration,
478 disableSessionRenewal: news.disableSessionRenewal,
479 queueAll: news.queueAll,
480 queueingMethod: news.queueingMethod,
481 queueingStatusCode: news.queueingStatusCode,
482 suspended: news.suspended,
483 jsonResponseEnabled: news.jsonResponseEnabled,
484 customPageHtml: news.customPageHtml,
485 defaultTemplateLanguage:
486 news.defaultTemplateLanguage as waitingRooms.UpdateWaitingRoomRequest["defaultTemplateLanguage"],
487 cookieSuffix: news.cookieSuffix,
488 cookieAttributes: news.cookieAttributes,
489 additionalRoutes: news.additionalRoutes,
490 enabledOriginCommands: news.enabledOriginCommands,
491 turnstileMode: news.turnstileMode,
492 turnstileAction: news.turnstileAction,
493});
494
495/**
496 * Cloudflare defaults for props with a documented default — used so that a
497 * prop the user removed from the program converges back to the default.
498 */
499const DEFAULTS = {
500 path: "/",
501 description: "",
502 sessionDuration: 5,
503 disableSessionRenewal: false,
504 queueAll: false,
505 queueingMethod: "fifo",
506 queueingStatusCode: 200,
507 suspended: false,
508 jsonResponseEnabled: false,
509 defaultTemplateLanguage: "en-US",
510 turnstileMode: "off",
511 turnstileAction: "log",
512} as const;
513
514/**
515 * Compare the observed room against the desired body. Props with a known
516 * default compare against the default when unspecified (so removals
517 * converge); props without one (cookieSuffix, customPageHtml, Advanced
518 * structures) only count when explicitly specified.
519 */
520const isDirty = (
521 observed: ObservedRoom,
522 name: string,
523 desired: ReturnType<typeof desiredBody>,
524): boolean => {
525 if ((observed.name ?? "") !== name) return true;
526 if ((observed.host ?? "") !== desired.host) return true;
527 if (observed.totalActiveUsers !== desired.totalActiveUsers) return true;
528 if (observed.newUsersPerMinute !== desired.newUsersPerMinute) return true;
529
530 const defaulted = <K extends keyof typeof DEFAULTS>(
531 key: K,
532 observedValue: unknown,
533 ): boolean => {
534 const want = desired[key as keyof typeof desired] ?? DEFAULTS[key];
535 return (observedValue ?? DEFAULTS[key]) !== want;
536 };
537 if (defaulted("path", observed.path)) return true;
538 if (defaulted("description", observed.description)) return true;
539 if (defaulted("sessionDuration", observed.sessionDuration)) return true;
540 if (defaulted("disableSessionRenewal", observed.disableSessionRenewal)) {
541 return true;
542 }
543 if (defaulted("queueAll", observed.queueAll)) return true;
544 if (defaulted("queueingMethod", observed.queueingMethod)) return true;
545 if (defaulted("queueingStatusCode", observed.queueingStatusCode)) {
546 return true;
547 }
548 if (defaulted("suspended", observed.suspended)) return true;
549 if (defaulted("jsonResponseEnabled", observed.jsonResponseEnabled)) {
550 return true;
551 }
552 if (defaulted("defaultTemplateLanguage", observed.defaultTemplateLanguage)) {
553 return true;
554 }
555 if (defaulted("turnstileMode", observed.turnstileMode)) return true;
556 if (defaulted("turnstileAction", observed.turnstileAction)) return true;
557
558 // Only-when-specified props (no reliable server default to reset to).
559 if (
560 desired.cookieSuffix !== undefined &&
561 (observed.cookieSuffix ?? "") !== desired.cookieSuffix
562 ) {
563 return true;
564 }
565 if (
566 desired.customPageHtml !== undefined &&
567 (observed.customPageHtml ?? "") !== desired.customPageHtml
568 ) {
569 return true;
570 }
571 if (desired.cookieAttributes !== undefined) {
572 const o = observed.cookieAttributes ?? {};
573 if (
574 (o.samesite ?? "auto") !== (desired.cookieAttributes.samesite ?? "auto")
575 ) {
576 return true;
577 }
578 if ((o.secure ?? "auto") !== (desired.cookieAttributes.secure ?? "auto")) {
579 return true;
580 }
581 }
582 if (desired.additionalRoutes !== undefined) {
583 const o = observed.additionalRoutes ?? [];
584 const d = desired.additionalRoutes;
585 if (
586 o.length !== d.length ||
587 o.some(
588 (route, i) =>
589 (route.host ?? "") !== (d[i]?.host ?? "") ||
590 (route.path ?? "/") !== (d[i]?.path ?? "/"),
591 )
592 ) {
593 return true;
594 }
595 }
596 if (desired.enabledOriginCommands !== undefined) {
597 const o = observed.enabledOriginCommands ?? [];
598 const d = desired.enabledOriginCommands;
599 if (o.length !== d.length || o.some((c, i) => c !== d[i])) return true;
600 }
601 return false;
602};
603
604const toAttributes = (
605 room:
606 | waitingRooms.GetWaitingRoomResponse
607 | waitingRooms.CreateWaitingRoomResponse
608 | waitingRooms.UpdateWaitingRoomResponse
609 | waitingRooms.ListWaitingRoomsResponse["result"][number],
610 zoneId: string,
611): Attributes => ({
612 // Cloudflare always echoes an id for a persisted room; distilled types it
613 // optional/nullable.
614 waitingRoomId: room.id ?? "",
615 zoneId,
616 name: room.name ?? "",
617 host: room.host ?? "",
618 path: room.path ?? "/",
619 totalActiveUsers: room.totalActiveUsers ?? 0,
620 newUsersPerMinute: room.newUsersPerMinute ?? 0,
621 description: room.description ?? "",
622 sessionDuration: room.sessionDuration ?? 5,
623 queueAll: room.queueAll ?? false,
624 queueingMethod: (room.queueingMethod ?? "fifo") as QueueingMethod,
625 queueingStatusCode: room.queueingStatusCode ?? 200,
626 suspended: room.suspended ?? false,
627 createdOn: room.createdOn ?? undefined,
628 modifiedOn: room.modifiedOn ?? undefined,
629});