1import * as snippets from "@distilled.cloud/cloudflare/snippets";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
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
12/**
13 * A single rule mapping a traffic expression to a snippet.
14 */
15export interface SnippetRule {
16 /**
17 * Name of the snippet to execute when the expression matches. Reference
18 * a `Snippet` resource's `name` output to create the dependency edge so
19 * the snippet is created before the rule (and deleted after it).
20 */
21 snippetName: string;
22 /**
23 * Cloudflare Rules language expression selecting the traffic the
24 * snippet runs on, e.g. `http.request.uri.path wildcard "/api/*"`.
25 */
26 expression: string;
27 /**
28 * Whether the rule is enabled.
29 * @default true
30 */
31 enabled?: boolean;
32 /**
33 * Informative description of the rule.
34 */
35 description?: string;
36}
37
38export interface SnippetRulesProps {
39 /**
40 * Zone the rules apply to. Stable — changing the zone triggers
41 * replacement.
42 */
43 zoneId: string;
44 /**
45 * Ordered list of snippet rules. The whole list is owned by this
46 * resource and replaced atomically on every change — rules managed
47 * elsewhere in the zone will be overwritten on deploy.
48 */
49 rules: SnippetRule[];
50}
51
52/**
53 * A snippet rule as Cloudflare reports it.
54 */
55export interface SnippetRuleAttribute {
56 /** Name of the snippet the rule executes. */
57 snippetName: string;
58 /** Rules language expression selecting matching traffic. */
59 expression: string;
60 /** Whether the rule is enabled. */
61 enabled: boolean;
62 /** Informative description of the rule. */
63 description: string | undefined;
64}
65
66export interface SnippetRulesAttributes {
67 /** Zone that owns the rule list. */
68 zoneId: string;
69 /** The ordered rule list as Cloudflare reports it. */
70 rules: SnippetRuleAttribute[];
71}
72
73export type SnippetRules = Resource<
74 "Cloudflare.Snippets.Rules",
75 SnippetRulesProps,
76 SnippetRulesAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * The ordered list of snippet rules for a Cloudflare zone.
83 *
84 * Snippet rules activate snippets against traffic: each rule pairs a
85 * Rules-language expression with the name of the snippet to execute on
86 * matching requests. The zone has exactly one rule list — this resource
87 * owns it in its entirety (PUT-replace semantics), so there should be at
88 * most one `SnippetRules` resource per zone.
89 *
90 * Safety: when there is no prior state and the zone already has a
91 * non-empty rule list, `read` reports it as `Unowned` and the engine
92 * refuses to take it over unless `--adopt` (or `adopt(true)`) is set.
93 * ### Activating Snippets
94 * **Example:** Route a path through a snippet
95 * ```typescript
96 * const snippet = yield* Cloudflare.Snippets.Snippet("HeaderSnippet", {
97 * zoneId: zone.zoneId,
98 * code: snippetCode,
99 * });
100 *
101 * yield* Cloudflare.Snippets.SnippetRules("Rules", {
102 * zoneId: zone.zoneId,
103 * rules: [
104 * {
105 * snippetName: snippet.name,
106 * expression: 'http.request.uri.path wildcard "/api/*"',
107 * description: "add headers to API responses",
108 * },
109 * ],
110 * });
111 * ```
112 *
113 * @resource
114 * @product Snippets
115 * @category Rules & Configuration
116 */
117export const SnippetRules = Resource<SnippetRules>("Cloudflare.Snippets.Rules");
118
119export const isSnippetRules = (value: unknown): value is SnippetRules =>
120 Predicate.hasProperty(value, "Type") &&
121 value.Type === "Cloudflare.Snippets.Rules";
122
123export const SnippetRulesProvider = () =>
124 Provider.succeed(SnippetRules, {
125 stables: ["zoneId"],
126
127 list: Effect.fn(function* () {
128 const { accountId } = yield* yield* CloudflareEnvironment;
129 // Snippet rules are a per-zone singleton with no account-wide list
130 // API — enumerate every zone and read its rule list. Zones with no
131 // rules have nothing to manage (matches `read` returning undefined
132 // for an empty list), so skip them.
133 const allZones = yield* listAllZones(accountId);
134 const rows = yield* Effect.forEach(
135 allZones.map((zone) => zone.id),
136 (zoneId) =>
137 listObservedRules(zoneId).pipe(
138 Effect.map((rules): SnippetRulesAttributes | undefined =>
139 rules.length === 0 ? undefined : { zoneId, rules },
140 ),
141 // Plan-gated zones (and eventually-consistent token 401/403s)
142 // reject the route; skip them. (`listObservedRules` already maps
143 // the snippet-rules 404 to an empty list, which becomes
144 // `undefined` above.)
145 Effect.catchTag(["Forbidden", "Unauthorized"], () =>
146 Effect.succeed(undefined),
147 ),
148 ),
149 { concurrency: 10 },
150 );
151 return rows.filter(
152 (row): row is SnippetRulesAttributes => row !== undefined,
153 );
154 }),
155
156 diff: Effect.fn(function* ({ olds, news, output }) {
157 const o = olds as SnippetRulesProps;
158 const n = news as SnippetRulesProps;
159 const oldZoneId =
160 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
161 if (
162 typeof n.zoneId === "string" &&
163 oldZoneId !== undefined &&
164 oldZoneId !== n.zoneId
165 ) {
166 return { action: "replace" } as const;
167 }
168 }),
169
170 read: Effect.fn(function* ({ olds, output }) {
171 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
172 if (!zoneId) return undefined;
173 const observed = yield* listObservedRules(zoneId);
174 if (observed.length === 0) return undefined;
175 const attrs: SnippetRulesAttributes = { zoneId, rules: observed };
176 // No prior state of our own but the zone already has rules — they
177 // may be managed by hand or by another tool. Refuse to take over
178 // unless adoption is explicitly allowed.
179 if (output === undefined) return Unowned(attrs);
180 return attrs;
181 }),
182
183 reconcile: Effect.fn(function* ({ news, output }) {
184 // Inputs have been resolved to concrete values by Plan.
185 const zoneId = (output?.zoneId ?? news.zoneId) as string;
186 const desired = news.rules.map((rule) => ({
187 snippetName: rule.snippetName as string,
188 expression: rule.expression,
189 enabled: rule.enabled ?? true,
190 description: rule.description,
191 }));
192
193 // Observe the live rule list and skip the PUT when it already
194 // matches the desired state.
195 const observed = yield* listObservedRules(zoneId);
196 if (!rulesEqual(desired, observed)) {
197 yield* snippets.putRule({ zoneId, rules: desired });
198 }
199
200 // Re-read so attributes reflect Cloudflare's canonical view.
201 const synced = yield* listObservedRules(zoneId);
202 return { zoneId, rules: synced } satisfies SnippetRulesAttributes;
203 }),
204
205 delete: Effect.fn(function* ({ output }) {
206 // An empty zone's DELETE can return "requested zone not found" even
207 // though the zone exists. Observe first so repeated deletes converge.
208 if ((yield* listObservedRules(output.zoneId)).length === 0) return;
209 yield* snippets
210 .deleteRule({ zoneId: output.zoneId })
211 .pipe(
212 Effect.catchTag(
213 ["SnippetRulesNotFound", "SnippetZoneNotFound"],
214 () => Effect.void,
215 ),
216 );
217 }),
218 });
219
220/**
221 * `ListRulesResponse` is untyped in distilled (`unknown`); the wire shape
222 * is an array of rule objects under `result`, camelized by the service key
223 * dictionary at decode (`snippet_name` → `snippetName`).
224 */
225interface WireRule {
226 readonly snippetName?: string;
227 readonly expression?: string;
228 readonly enabled?: boolean;
229 readonly description?: string | null;
230}
231
232const listObservedRules = (zoneId: string) =>
233 snippets.listRules({ zoneId }).pipe(
234 // A zone that has never had a snippet-rule list 404s — there is simply
235 // no rule list, which is equivalent to an empty one.
236 Effect.catchTag("SnippetRulesNotFound", () => Effect.succeed([])),
237 Effect.map((result): SnippetRuleAttribute[] => {
238 if (!Array.isArray(result)) return [];
239 return (result as WireRule[]).flatMap((rule) =>
240 rule.snippetName === undefined || rule.expression === undefined
241 ? []
242 : [
243 {
244 snippetName: rule.snippetName,
245 expression: rule.expression,
246 enabled: rule.enabled ?? true,
247 description: rule.description ?? undefined,
248 },
249 ],
250 );
251 }),
252 );
253
254const rulesEqual = (
255 desired: ReadonlyArray<{
256 snippetName: string;
257 expression: string;
258 enabled: boolean;
259 description: string | undefined;
260 }>,
261 observed: ReadonlyArray<SnippetRuleAttribute>,
262): boolean =>
263 desired.length === observed.length &&
264 desired.every((d, i) => {
265 const o = observed[i];
266 return (
267 d.snippetName === o.snippetName &&
268 d.expression === o.expression &&
269 d.enabled === o.enabled &&
270 (d.description ?? undefined) === o.description
271 );
272 });