1import * as rum from "@distilled.cloud/cloudflare/rum";
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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Rum.Site" as const;
14type TypeId = typeof TypeId;
15
16export type SiteProps = {
17 /**
18 * The hostname to measure, for gray-clouded sites (sites that are not
19 * proxied through Cloudflare). Embed the produced `snippet` (or the
20 * `siteToken`) in the page yourself.
21 *
22 * Exactly one of `host` or `zoneTag` must be provided. Switching between
23 * the two identity models triggers a replacement; changing the hostname
24 * itself is an in-place update.
25 */
26 host?: string;
27 /**
28 * The zone identifier, for orange-clouded sites (zones proxied through
29 * Cloudflare). Web Analytics attaches to the zone, and `autoInstall` can
30 * inject the measurement snippet at the edge. Changing this property
31 * triggers a replacement.
32 */
33 zoneTag?: string;
34 /**
35 * If enabled, the JavaScript measurement snippet is automatically
36 * injected for orange-clouded sites — no manual embed needed.
37 * @default false
38 */
39 autoInstall?: boolean;
40 /**
41 * Enables or disables RUM measurement. Only valid when `autoInstall` is
42 * `true`.
43 * @default true
44 */
45 enabled?: boolean;
46 /**
47 * If enabled, the JavaScript snippet will not be injected for visitors
48 * from the EU.
49 * @default false
50 */
51 lite?: boolean;
52};
53
54export type SiteAttributes = {
55 /**
56 * The Web Analytics site identifier. Stable for the lifetime of the site.
57 */
58 siteTag: string;
59 /**
60 * The Web Analytics site token used by the JavaScript beacon.
61 */
62 siteToken: string;
63 /**
64 * Encoded JavaScript snippet to embed in pages of gray-clouded sites.
65 */
66 snippet: string | undefined;
67 /**
68 * The identifier of the site's implicit ruleset. Rules that include or
69 * exclude traffic from measurement live under this ruleset.
70 */
71 rulesetId: string | undefined;
72 /**
73 * The Cloudflare account the site belongs to.
74 */
75 accountId: string;
76 /**
77 * The measured hostname (gray-clouded sites only).
78 */
79 host: string | undefined;
80 /**
81 * The zone identifier (orange-clouded sites only).
82 */
83 zoneTag: string | undefined;
84 /**
85 * Whether the JavaScript snippet is automatically injected for
86 * orange-clouded sites.
87 */
88 autoInstall: boolean;
89 /**
90 * When the site was created.
91 */
92 created: string | undefined;
93};
94
95export type Site = Resource<
96 TypeId,
97 SiteProps,
98 SiteAttributes,
99 never,
100 Providers
101>;
102
103/**
104 * A Cloudflare Web Analytics (RUM) site.
105 *
106 * A site measures real-user performance for either a plain hostname
107 * (gray-clouded — embed the produced `snippet` yourself) or a Cloudflare
108 * zone (orange-clouded — set `autoInstall` to inject the snippet at the
109 * edge). The site is identified by its auto-assigned `siteTag`; `host` and
110 * `autoInstall` are mutable in place, while switching between `host` and
111 * `zoneTag` identity models (or changing `zoneTag`) triggers a replacement.
112 *
113 * Web Analytics is available on free accounts.
114 * ### Measuring a hostname
115 * **Example:** Gray-clouded site (manual snippet embed)
116 * ```typescript
117 * const site = yield* Cloudflare.Rum.Site("Analytics", {
118 * host: "example.com",
119 * });
120 *
121 * // Embed in your HTML — contains the site token:
122 * const snippet = site.snippet;
123 * ```
124 *
125 * ### Measuring a zone
126 * **Example:** Orange-clouded site with automatic snippet injection
127 * ```typescript
128 * const zone = yield* Cloudflare.Zone.Zone("Zone", { name: "example.com" });
129 *
130 * yield* Cloudflare.Rum.Site("ZoneAnalytics", {
131 * zoneTag: zone.zoneId,
132 * autoInstall: true,
133 * });
134 * ```
135 *
136 * **Example:** Skip injection for EU visitors
137 * ```typescript
138 * yield* Cloudflare.Rum.Site("ZoneAnalytics", {
139 * zoneTag: zone.zoneId,
140 * autoInstall: true,
141 * lite: true,
142 * });
143 * ```
144 *
145 * @see https://developers.cloudflare.com/web-analytics/
146 *
147 * @resource
148 * @product RUM
149 * @category Observability & Analytics
150 */
151export const Site = Resource<Site>(TypeId);
152
153/**
154 * Returns true if the given value is a Site resource.
155 */
156export const isSite = (value: unknown): value is Site =>
157 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
158
159export const SiteProvider = () =>
160 Provider.succeed(Site, {
161 stables: ["siteTag", "siteToken", "accountId", "rulesetId", "created"],
162
163 diff: Effect.fn(function* ({ olds = {}, news, output }) {
164 const { accountId } = yield* yield* CloudflareEnvironment;
165 const o = olds as SiteProps;
166 const n = news as SiteProps;
167 if ((output?.accountId ?? accountId) !== accountId) {
168 return { action: "replace" } as const;
169 }
170 // Switching between host-based (gray-clouded) and zone-based
171 // (orange-clouded) measurement changes the site's identity model.
172 if ((o.zoneTag === undefined) !== (n.zoneTag === undefined)) {
173 return { action: "replace" } as const;
174 }
175 // zoneTag is Input<string>; compare only once both are concrete.
176 if (
177 typeof o.zoneTag === "string" &&
178 typeof n.zoneTag === "string" &&
179 o.zoneTag !== n.zoneTag
180 ) {
181 return { action: "replace" } as const;
182 }
183 return undefined;
184 }),
185
186 read: Effect.fn(function* ({ output, olds }) {
187 const { accountId } = yield* yield* CloudflareEnvironment;
188 const acct = output?.accountId ?? accountId;
189
190 // Owned path: refresh by our persisted site tag.
191 if (output?.siteTag) {
192 const observed = yield* getSite(acct, output.siteTag);
193 if (observed) return toAttributes(observed, acct);
194 }
195
196 // Adoption path: a site measuring the same host/zone may already
197 // exist. Web Analytics sites carry no ownership markers (and
198 // Cloudflare happily creates duplicates), so we cannot prove we
199 // created it — brand it `Unowned` so the engine refuses to take
200 // over unless `adopt` is set.
201 const host = output?.host ?? olds?.host;
202 const zoneTag =
203 output?.zoneTag ??
204 (typeof olds?.zoneTag === "string" ? olds.zoneTag : undefined);
205 if (host !== undefined || zoneTag !== undefined) {
206 const match = yield* findSite(acct, host, zoneTag);
207 if (match) return Unowned(toAttributes(match, acct));
208 }
209 return undefined;
210 }),
211
212 reconcile: Effect.fn(function* ({ news, output }) {
213 const { accountId } = yield* yield* CloudflareEnvironment;
214 // Inputs have been resolved to concrete strings by Plan.
215 const zoneTag = news.zoneTag as string | undefined;
216
217 // 1. Observe — the siteTag cached on `output` is a hint, not a
218 // guarantee: a missing site falls through to create.
219 let observed = output?.siteTag
220 ? yield* getSite(output.accountId ?? accountId, output.siteTag)
221 : undefined;
222
223 // 2. Ensure — create when missing. Cloudflare allows duplicate
224 // sites for the same host/zone, so there is no AlreadyExists
225 // race to tolerate. The create response omits `snippet` and
226 // `ruleset`, so re-read the full site state afterwards (riding
227 // out read-after-write lag on the freshly minted siteTag).
228 if (!observed) {
229 const created = yield* rum.createSiteInfo({
230 accountId,
231 host: news.host,
232 zoneTag,
233 autoInstall: news.autoInstall,
234 });
235 const siteTag = created.siteTag ?? "";
236 observed = yield* rum.getSiteInfo({ accountId, siteId: siteTag }).pipe(
237 Effect.retry({
238 while: (e) => e._tag === "SiteNotFound",
239 schedule: Schedule.exponential("250 millis"),
240 times: 5,
241 }),
242 );
243 // Greenfield with no post-create settings to apply — done.
244 if (news.enabled === undefined && news.lite === undefined) {
245 return toAttributes(observed, accountId);
246 }
247 }
248
249 // 3. Sync — diff observed cloud state against desired. The update
250 // API is a PUT that requires the full body (host/zoneTag/
251 // autoInstall must be re-sent). `enabled` and `lite` are write-
252 // only (never echoed back by the API), so they are applied
253 // whenever specified — observation cannot prove them converged.
254 const dirty =
255 (news.host !== undefined && observed.host !== news.host) ||
256 (observed.autoInstall ?? false) !== (news.autoInstall ?? false) ||
257 news.enabled !== undefined ||
258 news.lite !== undefined;
259 if (!dirty) {
260 return toAttributes(observed, accountId);
261 }
262
263 const updated = yield* rum.updateSiteInfo({
264 accountId,
265 siteId: observed.siteTag ?? "",
266 host: news.host,
267 zoneTag,
268 autoInstall: news.autoInstall,
269 enabled: news.enabled,
270 lite: news.lite,
271 });
272 return toAttributes(updated, accountId);
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 yield* rum
277 .deleteSiteInfo({
278 accountId: output.accountId,
279 siteId: output.siteTag,
280 })
281 .pipe(Effect.catchTag("SiteNotFound", () => Effect.void));
282 }),
283
284 // Account collection: Web Analytics sites are enumerated account-wide
285 // via the paginated site-info list. Hydrate each row into the exact
286 // `read` Attributes shape — the list response carries the same fields
287 // (siteTag/siteToken/snippet/ruleset/host) so no per-item re-read is
288 // needed.
289 list: Effect.fn(function* () {
290 const { accountId } = yield* yield* CloudflareEnvironment;
291 return yield* rum.listSiteInfos.pages({ accountId }).pipe(
292 Stream.runCollect,
293 Effect.map((chunk) =>
294 Array.from(chunk).flatMap((page) =>
295 (page.result ?? []).map((site) => toAttributes(site, accountId)),
296 ),
297 ),
298 );
299 }),
300 });
301
302type ObservedSite =
303 | rum.GetSiteInfoResponse
304 | rum.UpdateSiteInfoResponse
305 | rum.ListSiteInfosResponse["result"][number];
306
307/**
308 * Read a site by tag, mapping "gone" (`SiteNotFound`, Cloudflare error code
309 * 10015 `web_analytics.configuration.api.notFound`) to `undefined`.
310 */
311const getSite = (accountId: string, siteTag: string) =>
312 rum.getSiteInfo({ accountId, siteId: siteTag }).pipe(
313 Effect.map((site): ObservedSite | undefined => site),
314 Effect.catchTag("SiteNotFound", () => Effect.succeed(undefined)),
315 );
316
317/**
318 * Find a site measuring the given host (gray-clouded) or zone
319 * (orange-clouded). Cloudflare allows several sites for the same target, so
320 * pick the oldest for determinism.
321 */
322const findSite = (
323 accountId: string,
324 host: string | undefined,
325 zoneTag: string | undefined,
326) =>
327 rum.listSiteInfos.items({ accountId }).pipe(
328 Stream.runCollect,
329 Effect.map((chunk) =>
330 Array.from(chunk)
331 .filter((site) =>
332 host !== undefined
333 ? site.host === host
334 : // A zone-identity site has no `host`. Host-scoped sites on the
335 // zone's subdomains ALSO carry `ruleset.zoneTag`, so a zone
336 // probe must only match true zone sites — otherwise an
337 // unrelated host site satisfies the probe and the engine
338 // refuses adoption with OwnedBySomeoneElse.
339 zoneTag !== undefined &&
340 !site.host &&
341 site.ruleset?.zoneTag === zoneTag,
342 )
343 .sort((a, b) => (a.created ?? "").localeCompare(b.created ?? ""))
344 .at(0),
345 ),
346 );
347
348const toAttributes = (
349 site: ObservedSite,
350 accountId: string,
351): SiteAttributes => ({
352 siteTag: site.siteTag ?? "",
353 siteToken: site.siteToken ?? "",
354 snippet: site.snippet ?? undefined,
355 rulesetId: site.ruleset?.id ?? undefined,
356 accountId,
357 host: site.host ?? undefined,
358 zoneTag: site.ruleset?.zoneTag ?? undefined,
359 autoInstall: site.autoInstall ?? false,
360 created: site.created ?? undefined,
361});