1import * as snippets from "@distilled.cloud/cloudflare/snippets";
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 { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14import { listAllZones } from "../Zone/lookup.ts";
15
16export interface SnippetProps {
17 /**
18 * Zone the snippet belongs to. Stable — changing the zone triggers
19 * replacement.
20 */
21 zoneId: string;
22 /**
23 * Name of the snippet. Snippet names may only contain letters, numbers,
24 * and underscores (`[a-zA-Z0-9_]`) — the name is the snippet's identity
25 * within the zone, so changing it triggers replacement.
26 *
27 * If omitted, a unique name is generated from the app, stage, and
28 * logical ID.
29 *
30 * @default ${app}_${id}_${stage}_${suffix}
31 */
32 name?: string;
33 /**
34 * JavaScript source code of the snippet (ES module). Snippets are
35 * lightweight Workers-like scripts with hard platform limits: no
36 * environment variables or bindings, 5ms CPU time, 2MB memory, and a
37 * 32KB compressed size limit (500KB on Enterprise).
38 *
39 * Mutable — updated in place via upload.
40 */
41 code: string;
42 /**
43 * Filename of the snippet's main module as referenced in the upload.
44 *
45 * @default "snippet.js"
46 */
47 mainModule?: string;
48}
49
50export interface SnippetAttributes {
51 /** Name identifying the snippet within the zone. */
52 name: string;
53 /** Zone that owns this snippet. */
54 zoneId: string;
55 /** Filename of the snippet's main module. */
56 mainModule: string;
57 /** ISO8601 creation timestamp. */
58 createdOn: string | undefined;
59 /** ISO8601 last-modified timestamp. */
60 modifiedOn: string | undefined;
61}
62
63export type Snippet = Resource<
64 "Cloudflare.Snippets.Snippet",
65 SnippetProps,
66 SnippetAttributes,
67 never,
68 Providers
69>;
70
71/**
72 * A Cloudflare Snippet — a lightweight JavaScript module that runs on
73 * Cloudflare's edge to modify HTTP traffic for a zone.
74 *
75 * Uploading a snippet does not activate it: traffic only flows through a
76 * snippet once a {@link SnippetRules} rule references it with a matching
77 * expression.
78 *
79 * Safety: snippets carry no ownership markers. When there is no prior
80 * state, `read` looks the snippet up by name and reports an existing match
81 * as `Unowned`, so the engine refuses to take it over unless `--adopt`
82 * (or `adopt(true)`) is set.
83 * ### Creating a Snippet
84 * **Example:** Add a response header
85 * ```typescript
86 * const snippet = yield* Cloudflare.Snippets.Snippet("HeaderSnippet", {
87 * zoneId: zone.zoneId,
88 * code: `
89 * export default {
90 * async fetch(request) {
91 * const response = await fetch(request);
92 * const headers = new Headers(response.headers);
93 * headers.set("x-snippet", "hello");
94 * return new Response(response.body, { ...response, headers });
95 * },
96 * };
97 * `,
98 * });
99 * ```
100 *
101 * ### Activating with Snippet Rules
102 * **Example:** Route traffic through the snippet
103 * ```typescript
104 * yield* Cloudflare.Snippets.SnippetRules("Rules", {
105 * zoneId: zone.zoneId,
106 * rules: [
107 * {
108 * snippetName: snippet.name,
109 * expression: 'http.request.uri.path wildcard "/api/*"',
110 * },
111 * ],
112 * });
113 * ```
114 *
115 * @resource
116 * @product Snippets
117 * @category Rules & Configuration
118 */
119export const Snippet = Resource<Snippet>("Cloudflare.Snippets.Snippet");
120
121export const isSnippet = (value: unknown): value is Snippet =>
122 Predicate.hasProperty(value, "Type") &&
123 value.Type === "Cloudflare.Snippets.Snippet";
124
125const DEFAULT_MAIN_MODULE = "snippet.js";
126
127export const SnippetProvider = () =>
128 Provider.succeed(Snippet, {
129 stables: ["name", "zoneId", "createdOn"],
130
131 list: Effect.fn(function* () {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133 // Snippets live inside a zone with no account-wide enumeration
134 // API — fan out across every zone and list snippets per zone.
135 const zones = yield* listAllZones(accountId);
136 const rows = yield* Effect.forEach(
137 zones,
138 (zone) =>
139 snippets.listSnippets.pages({ zoneId: zone.id }).pipe(
140 Stream.runCollect,
141 Effect.map((chunk) =>
142 Array.from(chunk).flatMap((page) =>
143 (page.result ?? []).map((s): SnippetAttributes => ({
144 name: s.snippetName,
145 zoneId: zone.id,
146 // The list response carries no main module; snippets
147 // upload under the engine default and `read` falls
148 // back to it too, so report the same here.
149 mainModule: DEFAULT_MAIN_MODULE,
150 createdOn: s.createdOn,
151 modifiedOn: s.modifiedOn ?? undefined,
152 })),
153 ),
154 ),
155 // Plan-gated / partial-permission zones reject the route; skip.
156 Effect.catchTag("Forbidden", () =>
157 Effect.succeed([] as SnippetAttributes[]),
158 ),
159 ),
160 { concurrency: 10 },
161 );
162 return rows.flat();
163 }),
164
165 diff: Effect.fn(function* ({ id, olds, news, output }) {
166 if (!isResolved(news)) return undefined;
167 const o = olds as SnippetProps;
168 const n = news as SnippetProps;
169
170 // Name is the snippet's identity within the zone.
171 const oldName =
172 output?.name ?? (yield* createSnippetName(id, o.name as string));
173 // Auto-generated names are engine-owned: the deployed name stays
174 // authoritative even if the generator would name this id differently
175 // today. Only an explicit user-provided name can force a replace.
176 const name = n.name ?? oldName;
177 if (oldName !== name) {
178 return { action: "replace" } as const;
179 }
180 // zoneId is Input<string>; only compare once both are concrete.
181 const oldZoneId =
182 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
183 if (
184 typeof n.zoneId === "string" &&
185 oldZoneId !== undefined &&
186 oldZoneId !== n.zoneId
187 ) {
188 return { action: "replace" } as const;
189 }
190 }),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 // Owned path: refresh from persisted identity.
194 if (output?.name && output.zoneId) {
195 const observed = yield* getSnippetOrUndefined(
196 output.zoneId,
197 output.name,
198 );
199 if (observed) {
200 return toAttributes(observed, output.zoneId, output.mainModule);
201 }
202 return undefined;
203 }
204 // Cold read: derive the deterministic name and look it up. Snippets
205 // carry no ownership markers, so an existing match is `Unowned` and
206 // takeover is gated behind the adopt policy.
207 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
208 if (!zoneId) return undefined;
209 const name = output?.name ?? (yield* createSnippetName(id, olds?.name));
210 const observed = yield* getSnippetOrUndefined(zoneId, name);
211 if (observed) {
212 return Unowned(
213 toAttributes(
214 observed,
215 zoneId,
216 olds?.mainModule ?? DEFAULT_MAIN_MODULE,
217 ),
218 );
219 }
220 return undefined;
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output }) {
224 // Inputs have been resolved to concrete values by Plan.
225 const zoneId = (output?.zoneId ?? news.zoneId) as string;
226 const name = output?.name ?? (yield* createSnippetName(id, news.name));
227 const mainModule = news.mainModule ?? DEFAULT_MAIN_MODULE;
228
229 // `putSnippet` (PUT) is a true upsert — one call converges greenfield
230 // create, routine update, and adoption alike.
231 const file = yield* Effect.sync(
232 () =>
233 new File([news.code], mainModule, {
234 type: "application/javascript+module",
235 }),
236 );
237 const synced = yield* snippets.putSnippet({
238 zoneId,
239 snippetName: name,
240 metadata: { mainModule },
241 files: file,
242 });
243
244 return toAttributes(synced, zoneId, mainModule);
245 }),
246
247 delete: Effect.fn(function* ({ output }) {
248 yield* snippets
249 .deleteSnippet({
250 zoneId: output.zoneId,
251 snippetName: output.name,
252 })
253 // A snippet can only be deleted once no snippet rule references it.
254 // The engine deletes referencing `SnippetRules` first (dependency
255 // edge via `snippetName`), but the rule removal is eventually
256 // consistent — Cloudflare may still report `snippet is still used`
257 // for a short window. Bounded-retry that lag before giving up.
258 .pipe(
259 Effect.retry({
260 while: (e) => e._tag === "SnippetInUse",
261 schedule: Schedule.max([
262 Schedule.exponential("1 second"),
263 Schedule.recurs(8),
264 ]),
265 }),
266 Effect.catchTag("SnippetNotFound", () => Effect.void),
267 );
268 }),
269 });
270
271/**
272 * Snippet names only allow letters, numbers, and underscores; physical
273 * names are generated with `_` separators and any residual hyphens (from
274 * the app/stage/id) are normalized to underscores.
275 */
276const createSnippetName = (id: string, name: string | undefined) =>
277 Effect.gen(function* () {
278 if (name) return name;
279 const physical = yield* createPhysicalName({
280 id,
281 lowercase: true,
282 delimiter: "_",
283 });
284 return physical.replaceAll("-", "_");
285 });
286
287const getSnippetOrUndefined = (zoneId: string, snippetName: string) =>
288 snippets
289 .getSnippet({ zoneId, snippetName })
290 .pipe(Effect.catchTag("SnippetNotFound", () => Effect.succeed(undefined)));
291
292const toAttributes = (
293 observed: snippets.GetSnippetResponse,
294 zoneId: string,
295 mainModule: string,
296): SnippetAttributes => ({
297 name: observed.snippetName,
298 zoneId,
299 mainModule,
300 createdOn: observed.createdOn,
301 modifiedOn: observed.modifiedOn ?? undefined,
302});