Skip to content

Commit c77cb5e

Browse files
authored
Merge c55ba73 into 0a86694
2 parents 0a86694 + c55ba73 commit c77cb5e

File tree

8 files changed

+3090
-0
lines changed

8 files changed

+3090
-0
lines changed

alchemy-web/src/content/docs/providers/cloudflare/snippet-rule.mdx

Lines changed: 839 additions & 0 deletions
Large diffs are not rendered by default.

alchemy-web/src/content/docs/providers/cloudflare/snippet.mdx

Lines changed: 732 additions & 0 deletions
Large diffs are not rendered by default.

alchemy/src/cloudflare/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export * from "./secret-key.ts";
5959
export * from "./secret-ref.ts";
6060
export * from "./secret.ts";
6161
export * from "./secrets-store.ts";
62+
export * from "./snippet.ts";
63+
export * from "./snippet-rule.ts";
6264
export * from "./state.ts";
6365
export * from "./sveltekit/sveltekit.ts";
6466
export * from "./tanstack-start/tanstack-start.ts";
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import type { Context } from "../context.ts";
2+
import { Resource } from "../resource.ts";
3+
import { logger } from "../util/logger.ts";
4+
import { withExponentialBackoff } from "../util/retry.ts";
5+
import { CloudflareApiError } from "./api-error.ts";
6+
import { extractCloudflareResult } from "./api-response.ts";
7+
import {
8+
createCloudflareApi,
9+
type CloudflareApi,
10+
type CloudflareApiOptions,
11+
} from "./api.ts";
12+
import { type Snippet } from "./snippet.ts";
13+
import { findZoneForHostname, type Zone } from "./zone.ts";
14+
15+
/**
16+
* Input format for snippet rule operations
17+
* @internal
18+
*/
19+
export interface SnippetRuleInput {
20+
expression: string;
21+
snippetName: string;
22+
description?: string;
23+
enabled?: boolean;
24+
}
25+
26+
/**
27+
* Cloudflare API response format for a snippet rule
28+
* @internal
29+
*/
30+
export interface SnippetRuleResponse {
31+
id: string;
32+
expression: string;
33+
snippet_name: string;
34+
description?: string;
35+
enabled: boolean;
36+
last_updated: string;
37+
version: string;
38+
}
39+
40+
/**
41+
* Properties for creating or updating a batch of Snippet Rules
42+
*/
43+
export interface SnippetRuleProps extends CloudflareApiOptions {
44+
/**
45+
* The zone this rule batch belongs to
46+
* Can be a zone ID (32-char hex), zone name/hostname (e.g. "example.com"), or a Zone resource
47+
*/
48+
zone: string | Zone;
49+
50+
/**
51+
* Array of rules to manage for this zone
52+
* Rules are executed in the order they appear in this array
53+
*/
54+
rules: Array<{
55+
/**
56+
* The expression defining which traffic will match the rule
57+
* @example 'http.request.uri.path eq "/api"'
58+
*/
59+
expression: string;
60+
61+
/**
62+
* The snippet to execute (by name or Snippet resource)
63+
*/
64+
snippet: string | Snippet;
65+
66+
/**
67+
* Optional description of the rule
68+
*/
69+
description?: string;
70+
71+
/**
72+
* Whether the rule is enabled (default: true)
73+
*/
74+
enabled?: boolean;
75+
76+
/**
77+
* Optional ID for identifying this rule in the batch
78+
* Used internally for adoption and updates
79+
* @internal
80+
*/
81+
id?: string;
82+
}>;
83+
84+
/**
85+
* Whether to adopt existing rules matching the same expressions/snippets
86+
* @default false
87+
*/
88+
adopt?: boolean;
89+
}
90+
91+
/**
92+
* A Snippet Rule batch resource
93+
*/
94+
export type SnippetRule = Omit<SnippetRuleProps, "rules" | "adopt" | "zone"> & {
95+
/**
96+
* The identifier for this rule batch resource
97+
*/
98+
id: string;
99+
100+
/**
101+
* The zone ID
102+
*/
103+
zoneId: string;
104+
105+
/**
106+
* Rules managed by this resource
107+
*/
108+
rules: Array<{
109+
/**
110+
* The ID of the rule
111+
*/
112+
ruleId: string;
113+
114+
/**
115+
* The expression for the rule
116+
*/
117+
expression: string;
118+
119+
/**
120+
* The snippet name
121+
*/
122+
snippetName: string;
123+
124+
/**
125+
* Description of the rule
126+
*/
127+
description?: string;
128+
129+
/**
130+
* Whether the rule is enabled
131+
*/
132+
enabled: boolean;
133+
134+
/**
135+
* Last updated timestamp
136+
*/
137+
lastUpdated: Date;
138+
}>;
139+
140+
/**
141+
* Resource type identifier
142+
* @internal
143+
*/
144+
type: "snippet-rule";
145+
};
146+
147+
/**
148+
* Manages a batch of Snippet Rules for a zone
149+
*
150+
* The SnippetRule resource manages all snippet rules in a zone as a cohesive batch.
151+
* Rules are executed in the order they appear in the rules array. This resource
152+
* uses the batch update pattern for efficiency and atomic consistency.
153+
*
154+
* @example
155+
* // Create a batch of rules with explicit order
156+
* const rules = await SnippetRule("my-rules", {
157+
* zone: "example.com",
158+
* rules: [
159+
* {
160+
* expression: 'http.request.uri.path eq "/api"',
161+
* snippet: apiSnippet,
162+
* description: "API endpoint handler",
163+
* },
164+
* {
165+
* expression: 'http.request.uri.path eq "/admin"',
166+
* snippet: adminSnippet,
167+
* description: "Admin panel handler",
168+
* enabled: false,
169+
* }
170+
* ]
171+
* });
172+
*
173+
* @example
174+
* // Update rules maintaining explicit order
175+
* const updated = await SnippetRule("my-rules", {
176+
* zone: "example.com",
177+
* rules: [
178+
* // New first rule
179+
* {
180+
* expression: 'http.request.uri.path eq "/health"',
181+
* snippet: healthSnippet,
182+
* },
183+
* // Existing rules follow
184+
* {
185+
* id: previousRuleId,
186+
* expression: 'http.request.uri.path eq "/api"',
187+
* snippet: apiSnippet,
188+
* }
189+
* ]
190+
* });
191+
*/
192+
export const SnippetRule = Resource(
193+
"cloudflare::SnippetRule",
194+
async function (
195+
this: Context<SnippetRule>,
196+
id: string,
197+
props: SnippetRuleProps,
198+
): Promise<SnippetRule> {
199+
const api = await createCloudflareApi(props);
200+
let zoneId: string;
201+
if (this.output?.zoneId) {
202+
zoneId = this.output.zoneId;
203+
} else if (typeof props.zone === "string") {
204+
zoneId = props.zone.includes(".")
205+
? (await findZoneForHostname(api, props.zone)).zoneId
206+
: props.zone;
207+
} else {
208+
zoneId = props.zone.id;
209+
}
210+
211+
if (this.phase === "delete") {
212+
await deleteSnippetRules(api, zoneId);
213+
return this.destroy();
214+
}
215+
216+
const seenRuleDefinitions = new Set<string>();
217+
for (const rule of props.rules) {
218+
const key = `${rule.expression}:${
219+
typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name
220+
}`;
221+
if (seenRuleDefinitions.has(key)) {
222+
throw new Error(
223+
`Duplicate rule found: expression="${rule.expression}" with snippet="${
224+
typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name
225+
}"`,
226+
);
227+
}
228+
seenRuleDefinitions.add(key);
229+
}
230+
231+
const existingRules = await listSnippetRules(api, zoneId);
232+
const existingByKey = new Map(
233+
existingRules.map((r) => [`${r.expression}:${r.snippet_name}`, r]),
234+
);
235+
const apiRules: Array<SnippetRuleInput & { id?: string }> = [];
236+
237+
for (const rule of props.rules) {
238+
const snippetName =
239+
typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name;
240+
const key = `${rule.expression}:${snippetName}`;
241+
const existing = existingByKey.get(key);
242+
243+
if (rule.id || existing) {
244+
apiRules.push({
245+
id: rule.id || existing?.id,
246+
expression: rule.expression,
247+
snippetName,
248+
description: rule.description,
249+
enabled: rule.enabled ?? true,
250+
});
251+
} else {
252+
apiRules.push({
253+
expression: rule.expression,
254+
snippetName,
255+
description: rule.description,
256+
enabled: rule.enabled ?? true,
257+
});
258+
}
259+
}
260+
261+
const result = await withExponentialBackoff(
262+
async () => updateSnippetRules(api, zoneId, apiRules),
263+
(error: CloudflareApiError) => {
264+
const shouldRetry = error.errorData?.some(
265+
(e: any) =>
266+
e.code === 1002 ||
267+
e.message?.includes("doesn't exist") ||
268+
e.message?.includes("not found"),
269+
);
270+
if (shouldRetry) {
271+
logger.warn(
272+
`Snippet rules update encountered error, retrying due to propagation delay: ${error.message}`,
273+
);
274+
}
275+
return shouldRetry;
276+
},
277+
20,
278+
100,
279+
);
280+
281+
return {
282+
id,
283+
zoneId,
284+
rules: result.map((r) => ({
285+
ruleId: r.id,
286+
expression: r.expression,
287+
snippetName: r.snippet_name,
288+
description: r.description,
289+
enabled: r.enabled,
290+
lastUpdated: new Date(r.last_updated),
291+
})),
292+
type: "snippet-rule",
293+
};
294+
},
295+
);
296+
297+
/**
298+
* List all snippet rules in a zone
299+
* @internal
300+
*/
301+
export async function listSnippetRules(
302+
api: CloudflareApi,
303+
zoneId: string,
304+
): Promise<SnippetRuleResponse[]> {
305+
const result = await extractCloudflareResult<SnippetRuleResponse[] | null>(
306+
`list snippet rules in zone "${zoneId}"`,
307+
api.get(`/zones/${zoneId}/snippets/snippet_rules`),
308+
);
309+
310+
return result ?? [];
311+
}
312+
313+
/**
314+
* Update snippet rules in a zone (replaces all rules)
315+
* @internal
316+
*/
317+
export async function updateSnippetRules(
318+
api: CloudflareApi,
319+
zoneId: string,
320+
rules: Array<SnippetRuleInput & { id?: string }>,
321+
): Promise<SnippetRuleResponse[]> {
322+
const requestBody = {
323+
rules: rules.map((rule) => ({
324+
...(rule.id && { id: rule.id }),
325+
expression: rule.expression,
326+
snippet_name: rule.snippetName,
327+
description: rule.description,
328+
enabled: rule.enabled ?? true,
329+
})),
330+
};
331+
332+
const result = await extractCloudflareResult<SnippetRuleResponse[] | null>(
333+
`update snippet rules in zone "${zoneId}"`,
334+
api.put(`/zones/${zoneId}/snippets/snippet_rules`, requestBody),
335+
);
336+
337+
return result ?? [];
338+
}
339+
340+
/**
341+
* Delete all snippet rules in a zone
342+
* @internal
343+
*/
344+
export async function deleteSnippetRules(
345+
api: CloudflareApi,
346+
zoneId: string,
347+
): Promise<void> {
348+
try {
349+
await api.delete(`/zones/${zoneId}/snippets/snippet_rules`);
350+
} catch (error) {
351+
logger.error(`Error deleting snippet rules in zone ${zoneId}:`, error);
352+
throw error;
353+
}
354+
}

0 commit comments

Comments
 (0)