1import * as zaraz from "@distilled.cloud/cloudflare/zaraz";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import {
7 stripNullFields,
8 stripUndefinedFields,
9 unwrapRedacted,
10} from "../../Util/data.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { resolveZoneId, type Reference } from "../Zone/index.ts";
14import { listAllZones } from "../Zone/lookup.ts";
15import { defineZarazEvents } from "./ZarazEventTypes.ts";
16
17export type Workflow = zaraz.GetWorkflowResponse;
18export type Settings = zaraz.PutConfigRequest["settings"];
19export type Analytics = NonNullable<zaraz.PutConfigRequest["analytics"]>;
20export type Consent = NonNullable<zaraz.PutConfigRequest["consent"]>;
21
22export type ConfigProps = {
23 /**
24 * Zone whose Zaraz config should be managed. Accepts a zone id, a zone name
25 * (`example.com`), or a `{ zoneId, name? }` object.
26 */
27 zone: Reference;
28 /**
29 * Data layer compatibility mode.
30 */
31 dataLayer?: boolean;
32 /**
33 * Key used for Zaraz debug mode. Defaults to the current zone config value.
34 */
35 debugKey?: string;
36 /**
37 * Zaraz settings to merge into the current zone config.
38 */
39 settings?: Partial<Settings>;
40 /**
41 * Zaraz tools keyed by tool id. When omitted, existing tools are retained.
42 */
43 tools?: Record<string, unknown>;
44 /**
45 * Zaraz triggers keyed by trigger id. When omitted, existing triggers are
46 * retained.
47 */
48 triggers?: Record<string, unknown>;
49 /**
50 * Zaraz variables keyed by variable id. When omitted, existing variables are
51 * retained. Secret variable values are not returned by Cloudflare reads.
52 */
53 variables?: Record<string, unknown>;
54 /**
55 * Cloudflare Monitoring settings.
56 */
57 analytics?: Analytics;
58 /**
59 * Zaraz consent management configuration.
60 */
61 consent?: Consent;
62 /**
63 * Single Page Application support.
64 */
65 historyChange?: boolean;
66 /**
67 * Zaraz workflow mode. When omitted, the current workflow is retained.
68 */
69 workflow?: Workflow;
70 /**
71 * Whether destroy should restore Cloudflare's default Zaraz config.
72 *
73 * By default, destroy only removes this resource from Alchemy state and keeps
74 * the current zone-level Zaraz config intact.
75 * If `workflow` is set, `delete: true` restores the workflow to real-time
76 * mode, Cloudflare's default.
77 * @default false
78 */
79 delete?: boolean;
80};
81
82export type Config = Resource<
83 "Cloudflare.Zaraz.Config",
84 ConfigProps,
85 ConfigAttributes,
86 never,
87 Providers
88>;
89
90export type ConfigAttributes = {
91 /**
92 * Cloudflare zone id.
93 */
94 zoneId: string;
95 /**
96 * Data layer compatibility mode.
97 */
98 dataLayer: boolean;
99 /**
100 * Key used for Zaraz debug mode.
101 */
102 debugKey: string;
103 /**
104 * General Zaraz settings.
105 */
106 settings: zaraz.GetConfigResponse["settings"];
107 /**
108 * Zaraz tools keyed by tool id.
109 */
110 tools: Record<string, unknown>;
111 /**
112 * Zaraz triggers keyed by trigger id.
113 */
114 triggers: Record<string, unknown>;
115 /**
116 * Zaraz variables keyed by variable id.
117 */
118 variables: Record<string, unknown>;
119 /**
120 * Zaraz internal version of the config.
121 */
122 zarazVersion: number;
123 /**
124 * Cloudflare Monitoring settings.
125 */
126 analytics?: zaraz.GetConfigResponse["analytics"];
127 /**
128 * Consent management configuration.
129 */
130 consent?: zaraz.GetConfigResponse["consent"];
131 /**
132 * Single Page Application support.
133 */
134 historyChange?: boolean | null;
135 /**
136 * Current Zaraz workflow mode.
137 */
138 workflow: Workflow;
139};
140
141/**
142 * A Cloudflare Zaraz zone configuration.
143 *
144 * Cloudflare Zaraz is an edge-managed third-party tool manager and analytics
145 * event pipeline. See the
146 * {@link https://developers.cloudflare.com/zaraz/ | Cloudflare Zaraz docs} and
147 * {@link https://developers.cloudflare.com/zaraz/web-api/ | Web API docs}.
148 *
149 * Zaraz is a zone-level singleton. This resource reconciles the current zone
150 * config and workflow to the desired values while retaining existing settings
151 * for fields omitted from props.
152 *
153 * Destroy keeps the current Zaraz config by default to avoid wiping unrelated
154 * zone-level analytics setup. Set `delete: true` to restore Cloudflare's
155 * default Zaraz config on destroy.
156 * ### Managing Zaraz
157 * **Example:** Enable data layer compatibility
158 * ```typescript
159 * const zaraz = yield* Cloudflare.Zaraz.Config("Analytics", {
160 * zone: "example.com",
161 * dataLayer: true,
162 * });
163 * ```
164 *
165 * **Example:** Update Zaraz settings
166 * ```typescript
167 * const zaraz = yield* Cloudflare.Zaraz.Config("Analytics", {
168 * zone: "example.com",
169 * settings: {
170 * autoInjectScript: true,
171 * hideIPAddress: true,
172 * },
173 * });
174 * ```
175 *
176 * **Example:** Enable preview workflow
177 * ```typescript
178 * const zaraz = yield* Cloudflare.Zaraz.Config("Analytics", {
179 * zone: "example.com",
180 * workflow: "preview",
181 * });
182 * ```
183 *
184 * @resource
185 * @product Zaraz
186 * @category Performance & Reliability
187 */
188export const Config = Object.assign(
189 Resource<Config>("Cloudflare.Zaraz.Config", {
190 aliases: ["Cloudflare.ZarazConfig"],
191 }),
192 {
193 /**
194 * Define a type-only contract for the events sent through this Zaraz config.
195 *
196 * The returned value carries only types. Browser code should use
197 * Cloudflare's injected `window.zaraz` API at runtime.
198 */
199 events: defineZarazEvents,
200 },
201);
202
203export const ConfigProvider = () =>
204 Provider.succeed(Config, {
205 nuke: { singleton: true },
206 stables: ["zoneId"],
207 list: Effect.fn(function* () {
208 const { accountId } = yield* yield* CloudflareEnvironment;
209 // Zaraz config is a zone-level singleton with no account-wide list
210 // API — enumerate every zone and read its config (one per zone).
211 const allZones = yield* listAllZones(accountId);
212 const rows = yield* Effect.forEach(
213 allZones.map((zone) => zone.id),
214 (zoneId) =>
215 observe(zoneId).pipe(
216 // Best-effort account-wide fan-out: a zone where Zaraz isn't
217 // provisioned (rejects the route) or that the token can't read
218 // (missing permission / code-10000 auth blip surfaced as
219 // Unauthorized, or a 403/404) must be skipped, not fail the whole
220 // enumeration.
221 Effect.catchTag(
222 ["InvalidRoute", "Unauthorized", "Forbidden", "NotFound"],
223 () => Effect.succeed(undefined),
224 ),
225 ),
226 { concurrency: 10 },
227 );
228 return rows.filter(
229 (row): row is Config["Attributes"] => row !== undefined,
230 );
231 }),
232 diff: Effect.fn(function* ({ olds, news, output }) {
233 if (!output) return undefined;
234 if (!isResolved(news)) return undefined;
235
236 const zoneId = yield* resolve(news.zone);
237 if (zoneId !== output.zoneId) {
238 return { action: "replace" } as const;
239 }
240
241 const outputConfig = fromAttributes(output);
242 const comparableOutput = configForCompare(outputConfig, olds, news);
243 const desired = desiredConfig(outputConfig, news);
244 const desiredWorkflow = news.workflow ?? output.workflow;
245 if (
246 desiredWorkflow !== output.workflow ||
247 !deepEqual(
248 comparableConfig(comparableOutput),
249 comparableConfig(desired),
250 )
251 ) {
252 return { action: "update" } as const;
253 }
254 }),
255 read: Effect.fn(function* ({ olds, output }) {
256 const zoneId =
257 // `olds.zone` may be `undefined` when a `creating` row was persisted
258 // before upstream Outputs resolved — report "not found" then.
259 output?.zoneId ??
260 (olds?.zone !== undefined ? yield* resolve(olds.zone) : undefined);
261 if (!zoneId) return undefined;
262 return yield* observe(zoneId);
263 }),
264 reconcile: Effect.fn(function* ({ news, output }) {
265 const zoneId = output?.zoneId ?? (yield* resolve(news.zone));
266 const observed = yield* observe(zoneId);
267 const observedConfig = fromAttributes(observed);
268 const desired = desiredConfig(observedConfig, news);
269 const desiredWorkflow = news.workflow ?? observed.workflow;
270
271 const updatedConfig = deepEqual(
272 comparableConfig(observedConfig),
273 comparableConfig(desired),
274 )
275 ? observedConfig
276 : yield* zaraz.putConfig(toPutConfig(zoneId, desired));
277 const updatedWorkflow =
278 desiredWorkflow === observed.workflow
279 ? observed.workflow
280 : yield* zaraz.putZaraz({
281 zoneId,
282 workflow: desiredWorkflow,
283 });
284
285 return toAttributes(zoneId, updatedConfig, updatedWorkflow);
286 }),
287 delete: Effect.fn(function* ({ output, olds }) {
288 if (olds.delete !== true) return;
289 const defaults = yield* zaraz.getDefault({ zoneId: output.zoneId });
290 yield* zaraz.putConfig(toPutConfig(output.zoneId, defaults));
291 if (olds.workflow !== undefined) {
292 yield* zaraz.putZaraz({
293 zoneId: output.zoneId,
294 workflow: "realtime",
295 });
296 }
297 }),
298 });
299
300const resolve = Effect.fn(function* (zone: Reference) {
301 const { accountId } = yield* yield* CloudflareEnvironment;
302 return yield* resolveZoneId({
303 accountId,
304 zone,
305 hostname: typeof zone === "string" ? zone : (zone.name ?? ""),
306 });
307});
308
309const observe = (zoneId: string) =>
310 Effect.all({
311 config: zaraz.getConfig({ zoneId }),
312 workflow: zaraz.getWorkflow({ zoneId }),
313 }).pipe(
314 Effect.map(({ config, workflow }) =>
315 toAttributes(zoneId, config, workflow),
316 ),
317 );
318
319type ConfigResponse =
320 | zaraz.GetConfigResponse
321 | zaraz.PutConfigResponse
322 | zaraz.GetDefaultResponse;
323
324const toAttributes = (
325 zoneId: string,
326 config: ConfigResponse,
327 workflow: Workflow,
328): Config["Attributes"] => ({
329 zoneId,
330 dataLayer: config.dataLayer,
331 debugKey: config.debugKey,
332 settings: config.settings,
333 tools: config.tools,
334 triggers: config.triggers,
335 variables: config.variables,
336 zarazVersion: config.zarazVersion,
337 analytics: config.analytics,
338 consent: config.consent,
339 historyChange: config.historyChange,
340 workflow,
341});
342
343const fromAttributes = (attrs: ConfigAttributes): ConfigResponse => ({
344 dataLayer: attrs.dataLayer,
345 debugKey: attrs.debugKey,
346 settings: attrs.settings,
347 tools: attrs.tools,
348 triggers: attrs.triggers,
349 variables: attrs.variables,
350 zarazVersion: attrs.zarazVersion,
351 analytics: attrs.analytics,
352 consent: attrs.consent,
353 historyChange: attrs.historyChange,
354});
355
356const desiredConfig = (
357 observed: ConfigResponse,
358 props: ConfigProps,
359): ConfigResponse => ({
360 dataLayer: props.dataLayer ?? observed.dataLayer,
361 debugKey: props.debugKey ?? observed.debugKey,
362 settings: {
363 ...stripNullFields(observed.settings),
364 ...props.settings,
365 },
366 tools: props.tools ?? observed.tools,
367 triggers: props.triggers ?? observed.triggers,
368 variables: props.variables ?? observed.variables,
369 zarazVersion: observed.zarazVersion,
370 analytics:
371 props.analytics ??
372 (observed.analytics ? stripNullFields(observed.analytics) : undefined),
373 consent:
374 props.consent ??
375 (observed.consent ? stripNullFields(observed.consent) : undefined),
376 historyChange: props.historyChange ?? observed.historyChange,
377});
378
379const toPutConfig = (
380 zoneId: string,
381 config: ConfigResponse,
382): zaraz.PutConfigRequest =>
383 stripUndefinedFields({
384 zoneId,
385 dataLayer: config.dataLayer,
386 debugKey: config.debugKey,
387 settings: stripNullFields(config.settings) as Settings,
388 tools: unwrapRedacted(config.tools) as Record<string, unknown>,
389 triggers: unwrapRedacted(config.triggers) as Record<string, unknown>,
390 variables: unwrapRedacted(config.variables) as Record<string, unknown>,
391 zarazVersion: config.zarazVersion,
392 analytics: config.analytics
393 ? (stripNullFields(config.analytics) as Analytics)
394 : undefined,
395 consent: config.consent
396 ? (stripNullFields(config.consent) as Consent)
397 : undefined,
398 historyChange: config.historyChange ?? undefined,
399 }) as zaraz.PutConfigRequest;
400
401const comparableConfig = (config: ConfigResponse) => {
402 const { zoneId: _, ...request } = toPutConfig("", config);
403 return request;
404};
405
406const configForCompare = (
407 observed: ConfigResponse,
408 oldProps: ConfigProps | undefined,
409 props: ConfigProps,
410): ConfigResponse => {
411 if (
412 props.variables === undefined ||
413 oldProps?.variables === undefined ||
414 !deepEqual(oldProps.variables, props.variables)
415 ) {
416 return observed;
417 }
418
419 // If props already requested these variables in the previous state, compare
420 // against props instead of Cloudflare's readback because secret variable
421 // values are intentionally write-only in the Zaraz API.
422 return {
423 ...observed,
424 variables: props.variables,
425 };
426};