diff --git a/alchemy-web/src/content/docs/providers/cloudflare/snippet-rule.mdx b/alchemy-web/src/content/docs/providers/cloudflare/snippet-rule.mdx new file mode 100644 index 000000000..d3d1eb9f7 --- /dev/null +++ b/alchemy-web/src/content/docs/providers/cloudflare/snippet-rule.mdx @@ -0,0 +1,839 @@ +--- +title: SnippetRule +description: Learn how to manage and orchestrate Cloudflare Snippet Rules to control execution order and conditions for edge JavaScript execution. +--- + +Control when and where your Snippets execute. [Snippet Rules](https://developers.cloudflare.com/rules/snippets/) define the conditions under which your edge JavaScript runs, and critically, the **order** in which rules are evaluated. + +Snippets without rules never execute. SnippetRule gives you granular control over execution flow at the zone level. + +## Quick Start + +Create a set of rules that execute snippets conditionally: + +```ts +import { Snippet, SnippetRule } from "alchemy/cloudflare"; + +// Create snippets +const authSnippet = await Snippet("auth", { + zone: "example.com", + name: "auth", + script: "export default { async fetch(r) { return fetch(r); } }", +}); + +const corsSnippet = await Snippet("cors", { + zone: "example.com", + name: "cors", + script: "export default { async fetch(r) { return fetch(r); } }", +}); + +// Orchestrate execution with rules +const rules = await SnippetRule("api-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + description: "Validate auth on API endpoints", + enabled: true, + }, + { + expression: 'http.request.uri.path contains "/api"', + snippet: corsSnippet, + description: "Add CORS headers to API responses", + enabled: true, + }, + ], +}); +``` + +**Key points:** +- Rules execute in **array order** - first rule first, last rule last +- Rules are **zone-level** - you manage all rules for a zone in one batch +- Each snippet is referenced by name or resource +- Order determines execution flow + +:::tip +Use SnippetRule to compose multiple snippets into a processing pipeline. The order matters: auth before caching, headers before response modification. +::: + +:::caution[Plan Requirement] +SnippetRules are available on **Pro, Business, and Enterprise plans only**. Not available on Free plan. +::: + +## Execution Order + +Snippet Rules execute **after** these Cloudflare features: +1. Single Redirects +2. URL Rewrite Rules +3. Configuration Rules +4. Origin Rules +5. Bulk Redirects +6. Managed Transforms +7. Request Header Transform Rules +8. Cache Rules + +This means rules evaluate requests after they've been processed by these features. + +## SnippetRule Configuration + +Configure rule batches with expressions, snippet references, and execution control: + +```ts +import { Snippet, SnippetRule } from "alchemy/cloudflare"; + +const rules = await SnippetRule("api-rules", { + zone: "example.com", // Zone ID, hostname, or Zone resource (required) + + rules: [ // Array of rules (order determines execution sequence) + { + expression: 'http.request.uri.path eq "/api"', // Filter expression (required) + snippet: authSnippet, // Snippet resource or string name (required) + description: "Validate API auth", // Human-readable description (optional) + enabled: true, // Enable/disable rule (default: true) + }, + { + expression: 'http.request.method eq "POST"', + snippet: "validation_handler", // String reference to snippet name + description: "Validate POST data", + enabled: true, + }, + { + expression: "true", // Catch-all expression + snippet: loggingSnippet, + description: "Log all requests", + enabled: false, // Rule exists but won't execute + }, + ], + + // Optional configuration + adopt: true, // Adopt existing rules (default: false) + + // Cloudflare API credentials (optional - uses environment variables by default) + accountId: "your-account-id", // Cloudflare account ID (default: CLOUDFLARE_ACCOUNT_ID env var) + apiToken: "your-api-token", // Cloudflare API token (default: CLOUDFLARE_API_TOKEN env var) +}); +``` + +**Expression Language:** + +Rules use [Cloudflare's Filter Expression Language](https://developers.cloudflare.com/ruleset-engine/rules-language/) supporting: +- **Path matching**: `http.request.uri.path eq "/api"` +- **Header checks**: `http.request.headers["User-Agent"] contains "bot"` +- **Country filtering**: `cf.country in ("US" "CA" "MX")` +- **Method filtering**: `http.request.method eq "POST"` +- **Complex logic**: `(condition1 and condition2) or condition3` + +:::caution +Rules execute in **array order**. First rule in the array executes first. Changing the order changes execution behavior. +::: + +## Understanding Zone-Level Rules + +Unlike individual resources, SnippetRule manages **all snippet rules for a zone** in a single batch operation. This is how Cloudflare's API works: + +```ts +// You define the complete rule set for the zone +const rules = await SnippetRule("orchestration", { + zone: "example.com", + rules: [ + // Rule 1 - runs first + { expression: 'http.request.uri.path eq "/admin"', snippet: "auth" }, + // Rule 2 - runs second + { expression: 'http.request.uri.path contains "/api"', snippet: "cors" }, + // Rule 3 - runs third + { expression: "true", snippet: "logging" }, + ], +}); + +// Update replaces the entire zone's rule set +const updated = await SnippetRule("orchestration", { + zone: "example.com", + rules: [ + // New complete rule set - old rules are replaced + { expression: 'http.request.uri.path eq "/v2"', snippet: "v2_auth" }, + ], +}); +``` + +**Why zone-level?** The Cloudflare API's `PUT /zones/{zone_id}/snippets/snippet_rules` endpoint updates all rules at once. Alchemy respects this design rather than adding complexity with per-rule updates. + +## Zone Specification + +Specify your zone using any format: + +```ts +// Zone ID (fastest - direct) +await SnippetRule("rules-1", { + zone: "023e105f4ecef8ad9ca31a8372d0c353", + rules: [{ expression: "true", snippet: "my-snippet" }], +}); + +// Zone hostname (auto-resolved) +await SnippetRule("rules-2", { + zone: "example.com", + rules: [{ expression: "true", snippet: "my-snippet" }], +}); + +// Zone resource (Alchemy composition) +const zone = await Zone("main", { name: "example.com", type: "full" }); +await SnippetRule("rules-3", { + zone, + rules: [{ expression: "true", snippet: "my-snippet" }], +}); +``` + +:::note +Zone IDs are fastest (no lookup). Hostnames are convenient but trigger an API call to resolve. Zone resources integrate best with other Alchemy resources. +::: + +## Rule Expressions + +Control execution with Cloudflare's powerful filter language: + +### Exact Path Matching + +```ts +const rules = await SnippetRule("path-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api/users"', + snippet: authSnippet, + description: "Auth for exact path", + }, + { + expression: 'http.request.uri.path eq "/admin"', + snippet: adminSnippet, + description: "Admin auth", + }, + ], +}); +``` + +### Path Prefixes + +```ts +const rules = await SnippetRule("prefix-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path starts_with "/api/"', + snippet: apiAuthSnippet, + description: "Auth all /api/* paths", + }, + { + expression: 'http.request.uri.path starts_with "/admin/"', + snippet: adminSnippet, + description: "Auth all /admin/* paths", + }, + ], +}); +``` + +### Contains Pattern + +```ts +const rules = await SnippetRule("pattern-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path contains "download"', + snippet: downloadSnippet, + description: "Handle downloads", + }, + { + expression: 'http.request.uri.query contains "debug=true"', + snippet: debugSnippet, + description: "Debug mode", + }, + ], +}); +``` + +### Header-Based Rules + +```ts +const rules = await SnippetRule("header-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.headers["User-Agent"] contains "bot"', + snippet: botSnippet, + description: "Handle bots", + }, + { + expression: 'http.request.headers["X-API-Version"] eq "v2"', + snippet: v2ApiSnippet, + description: "API v2 handler", + }, + { + expression: 'http.request.headers["X-Forwarded-Proto"] eq "https"', + snippet: secureSnippet, + description: "HTTPS-only handler", + }, + ], +}); +``` + +### Country/IP-Based Rules + +```ts +const rules = await SnippetRule("geo-rules", { + zone: "example.com", + rules: [ + { + expression: 'cf.country eq "US"', + snippet: usSnippet, + description: "US-specific handling", + }, + { + expression: 'cf.country in ("UK" "DE" "FR")', + snippet: euSnippet, + description: "EU-specific handling (GDPR)", + }, + { + expression: 'ip.geoip.is_datacenter', + snippet: dcSnippet, + description: "Datacenter traffic", + }, + ], +}); +``` + +### Method-Based Rules + +```ts +const rules = await SnippetRule("method-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.method eq "POST"', + snippet: postSnippet, + description: "POST request handling", + }, + { + expression: 'http.request.method in ("PUT" "PATCH" "DELETE")', + snippet: mutationSnippet, + description: "Mutation request handling", + }, + { + expression: 'http.request.method eq "OPTIONS"', + snippet: corsPreflightSnippet, + description: "CORS preflight", + }, + ], +}); +``` + +### Complex Combinations + +```ts +const rules = await SnippetRule("complex-rules", { + zone: "example.com", + rules: [ + { + expression: `http.request.uri.path starts_with "/api/" and http.request.method eq "POST"`, + snippet: apiCreateSnippet, + description: "API creation endpoint", + }, + { + expression: `(cf.country in ("US" "CA" "MX")) and (http.request.headers["X-Premium"] eq "true")`, + snippet: premiumNorthAmericaSnippet, + description: "Premium North American users", + }, + { + expression: `http.request.uri.path starts_with "/api/" and not http.request.headers["Authorization"] contains "Bearer"`, + snippet: apiAuthErrorSnippet, + description: "Catch missing auth", + }, + ], +}); +``` + +:::tip +See [Cloudflare Filter Expression Language](https://developers.cloudflare.com/ruleset-engine/rules-language/) for complete expression reference. +::: + +## Execution Order (Critical!) + +**The order of rules in your array is the order they execute.** This is the key to SnippetRule DX: + +```ts +const rules = await SnippetRule("pipeline", { + zone: "example.com", + rules: [ + // 1. FIRST - Validate auth early + { + expression: 'http.request.uri.path eq "/api/protected"', + snippet: authSnippet, + description: "1. Validate auth (first)", + }, + // 2. SECOND - Validate input + { + expression: 'http.request.uri.path eq "/api/protected"', + snippet: validationSnippet, + description: "2. Validate input (second)", + }, + // 3. THIRD - Log request + { + expression: 'http.request.uri.path eq "/api/protected"', + snippet: loggingSnippet, + description: "3. Log request (third)", + }, + // 4. FOURTH - Call origin (implicit - no snippet matches) + ], +}); +``` + +**Execution flow:** +``` +Request arrives + ↓ +Rule 1 (auth) - checks expression, if matches runs authSnippet + ↓ +Rule 2 (validation) - checks expression, if matches runs validationSnippet + ↓ +Rule 3 (logging) - checks expression, if matches runs loggingSnippet + ↓ +Response sent +``` + +## Reordering Rules + +Change the execution order by reordering the array: + +```ts +// Original order +let rules = await SnippetRule("order", { + zone: "example.com", + rules: [ + { expression: "true", snippet: snippetA }, // 1st + { expression: "true", snippet: snippetB }, // 2nd + { expression: "true", snippet: snippetC }, // 3rd + ], +}); + +// Change order - reorder the array +rules = await SnippetRule("order", { + zone: "example.com", + rules: [ + { expression: "true", snippet: snippetC }, // Now 1st + { expression: "true", snippet: snippetA }, // Now 2nd + { expression: "true", snippet: snippetB }, // Now 3rd + ], +}); + +// The resource maintains atomicity - all rules update together +``` + +:::caution +Reordering is atomic - the entire zone's rules update together. No partial states. +::: + +## Snippets as References + +Reference snippets by Snippet resource or by string name: + +```ts +// Create snippet resources +const authSnippet = await Snippet("auth", { + zone: "example.com", + name: "auth_handler", + script: "...", +}); + +const corsSnippet = await Snippet("cors", { + zone: "example.com", + name: "cors_handler", + script: "...", +}); + +// Reference by resource (best - type-safe) +await SnippetRule("rules-typed", { + zone: "example.com", + rules: [ + { expression: 'http.request.uri.path eq "/api"', snippet: authSnippet }, + { expression: "true", snippet: corsSnippet }, + ], +}); + +// Reference by string name (convenient) +await SnippetRule("rules-strings", { + zone: "example.com", + rules: [ + { expression: 'http.request.uri.path eq "/api"', snippet: "auth_handler" }, + { expression: "true", snippet: "cors_handler" }, + ], +}); +``` + +**Best practice:** Use resources for composition, strings for one-off references. + +## Descriptions & Organization + +Document your rules for future maintenance: + +```ts +const rules = await SnippetRule("documented", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path starts_with "/api/"', + snippet: authSnippet, + description: "Validate JWT token for all API requests", + enabled: true, + }, + { + expression: 'http.request.uri.path contains "/webhook"', + snippet: webhookSnippet, + description: "Verify webhook signature before processing", + enabled: true, + }, + { + expression: "true", + snippet: corsSnippet, + description: "Add CORS headers to all responses", + enabled: true, + }, + ], +}); +``` + +## Enable/Disable Rules + +Control rule execution without deletion: + +```ts +const rules = await SnippetRule("toggles", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + enabled: true, // This runs + }, + { + expression: 'http.request.uri.path eq "/debug"', + snippet: debugSnippet, + enabled: false, // This is skipped + }, + ], +}); + +// Later, enable debug mode +const updated = await SnippetRule("toggles", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + enabled: true, + }, + { + expression: 'http.request.uri.path eq "/debug"', + snippet: debugSnippet, + enabled: true, // Now it runs + }, + ], +}); +``` + +## Empty Rules (Clear All) + +Remove all rules from a zone: + +```ts +// Clear all rules +const cleared = await SnippetRule("empty-rules", { + zone: "example.com", + rules: [], // Empty array = remove all rules +}); + +// Restore later +const restored = await SnippetRule("empty-rules", { + zone: "example.com", + rules: [ + { expression: "true", snippet: mySnippet }, + ], +}); +``` + +:::warning +Clearing rules with `rules: []` removes all snippets from execution. Snippets still exist but won't run until rules are re-added. +::: + +## Adoption Pattern + +Adopt existing rules to prevent conflicts: + +```ts +const rules = await SnippetRule("production-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + }, + ], + adopt: true, // Use existing rules if present, don't error +}); +``` + +:::note +When `adopt: true`, SnippetRule will update any existing rules rather than failing on conflict. +::: + +## Update & Reorder Patterns + +### A/B Testing with Rules + +```ts +const abTestRules = await SnippetRule("ab-test", { + zone: "example.com", + rules: [ + // 50/50 split + { + expression: 'cf.random() < 0.5', + snippet: variantASnippet, + description: "A/B Test - Variant A (50%)", + }, + { + expression: 'cf.random() >= 0.5', + snippet: variantBSnippet, + description: "A/B Test - Variant B (50%)", + }, + ], +}); +``` + +### Canary Deployment + +```ts +const canaryRules = await SnippetRule("canary", { + zone: "example.com", + rules: [ + // 5% traffic to new version + { + expression: 'cf.random() < 0.05', + snippet: newVersionSnippet, + description: "Canary - new version (5%)", + }, + // 95% traffic to stable version + { + expression: true, + snippet: stableVersionSnippet, + description: "Canary - stable version (95%)", + }, + ], +}); + +// Later, increase canary to 50% +const increased = await SnippetRule("canary", { + zone: "example.com", + rules: [ + // Now 50% traffic to new version + { + expression: 'cf.random() < 0.5', + snippet: newVersionSnippet, + description: "Canary - new version (50%)", + }, + // 50% traffic to stable version + { + expression: true, + snippet: stableVersionSnippet, + description: "Canary - stable version (50%)", + }, + ], +}); +``` + +### Feature Flags + +```ts +const featureFlags = await SnippetRule("features", { + zone: "example.com", + rules: [ + // Feature A - enabled for specific users + { + expression: 'http.request.headers["X-User-ID"] in ("user-1" "user-2" "user-3")', + snippet: featureASnippet, + description: "Feature A - beta testing", + enabled: true, + }, + // Feature B - enabled in specific region + { + expression: 'cf.country in ("US" "CA")', + snippet: featureBSnippet, + description: "Feature B - North America only", + enabled: true, + }, + // Default behavior + { + expression: "true", + snippet: defaultSnippet, + description: "Default behavior", + enabled: true, + }, + ], +}); +``` + +## Error Handling + +### Duplicate Rules Prevention + +Duplicate rule definitions are detected and error: + +```ts +// ❌ Error - duplicate rules +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + }, + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, // Error: duplicate + }, + ], +}); + +// ✅ Correct - unique combinations +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: authSnippet, + }, + { + expression: 'http.request.uri.path contains "/api"', // Different expression + snippet: loggingSnippet, + }, + ], +}); +``` + +## Troubleshooting + +### Rules Not Executing + +Rules created but snippets aren't running? + +```ts +// ❌ Problem: Empty rules +await SnippetRule("rules", { + zone: "example.com", + rules: [], // No rules = no execution +}); + +// ✅ Solution: Add matching rules +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { + expression: "true", // Match all requests + snippet: mySnippet, + enabled: true, + }, + ], +}); +``` + +### Wrong Execution Order + +Check if rules are in the correct order: + +```ts +// ❌ Problem: Wrong order - logging before auth +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { expression: "true", snippet: loggingSnippet }, // Runs 1st + { expression: "true", snippet: authSnippet }, // Runs 2nd (too late) + ], +}); + +// ✅ Solution: Reorder correctly +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { expression: "true", snippet: authSnippet }, // Runs 1st + { expression: "true", snippet: loggingSnippet }, // Runs 2nd + ], +}); +``` + +### Expression Syntax Error + +Invalid filter expression? + +```ts +// ❌ Error: Invalid syntax +{ + expression: 'http.request.path eq "/api"', // "path" is wrong + snippet: mySnippet, +} + +// ✅ Correct +{ + expression: 'http.request.uri.path eq "/api"', // Use "uri.path" + snippet: mySnippet, +} +``` + +### Snippet Not Found + +Referenced snippet doesn't exist: + +```ts +// ❌ Error: Snippet name doesn't exist +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { + expression: "true", + snippet: "nonexistent-snippet", // Must exist first + }, + ], +}); + +// ✅ Solution: Create snippet first +const snippet = await Snippet("auth", { + zone: "example.com", + name: "auth", // This name must match + script: "...", +}); + +await SnippetRule("rules", { + zone: "example.com", + rules: [ + { + expression: "true", + snippet: "auth", // Now it exists + }, + ], +}); +``` + +## Best Practices + +1. **Order matters** - Put authentication before logging, headers before response modification +2. **Be specific** - Use precise expressions to avoid running snippets unnecessarily +3. **Document rules** - Add descriptions explaining why each rule exists +4. **Test before deploy** - Use feature flags and canary patterns for safe rollout +5. **Keep it simple** - Complex expressions are hard to maintain +6. **Name snippets clearly** - `auth_handler` not `snippet_1` +7. **Monitor execution** - Add logging snippet to track rule flow +8. **Plan availability** - SnippetRules only available on Pro, Business, Enterprise plans +9. **No version rollback** - Cloudflare doesn't support versioning for Snippets/SnippetRules. Test rule changes carefully before deployment. + +## Next Steps + +- **Snippets**: See [Snippet](/providers/cloudflare/snippet) for snippet creation and examples +- **Zones**: See [Zone](/providers/cloudflare/zone) for zone management +- **Filter Language**: See [Cloudflare Filter Expression Language](https://developers.cloudflare.com/ruleset-engine/rules-language/) for full expression reference +- Join the [Alchemy Discord](https://discord.gg/xGnGbfxq) to get help and share your experiences. \ No newline at end of file diff --git a/alchemy-web/src/content/docs/providers/cloudflare/snippet.mdx b/alchemy-web/src/content/docs/providers/cloudflare/snippet.mdx new file mode 100644 index 000000000..f4b9b572c --- /dev/null +++ b/alchemy-web/src/content/docs/providers/cloudflare/snippet.mdx @@ -0,0 +1,732 @@ +--- +title: Snippet +description: Learn how to deploy, manage, and execute lightweight JavaScript code at the edge with Cloudflare Snippets for request/response modification. +--- + +Execute lightweight JavaScript code at the edge. [Cloudflare Snippets](https://developers.cloudflare.com/rules/snippets/) run directly on Cloudflare's network to modify HTTP requests and responses without touching your origin server. + +## Quick Start + +Deploy a basic snippet that modifies response headers: + +```ts +import { Snippet } from "alchemy/cloudflare"; + +const snippet = await Snippet("cors-handler", { + zone: "example.com", // Zone ID, hostname, or Zone resource + name: "cors-handler", + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set("Access-Control-Allow-Origin", "*"); + return response; + } +} + `.trim(), +}); +``` + +This creates a snippet that: +- Runs on every request to your zone +- Adds a CORS header to responses +- Takes < 5ms to execute +- Requires no changes to your origin + +:::caution[Plan Requirement] +Snippets are available on **Pro, Business, and Enterprise plans only**. Not available on Free plan. +::: + +:::tip +Snippets are perfect for quick edge logic: adding headers, JWT validation, redirects, or response modification without the overhead of a full Worker. +::: + +## Snippet Configuration + +Configure all aspects of your snippet including zone targeting, code source, and adoption behavior: + +```ts +import { Snippet } from "alchemy/cloudflare"; + +const snippet = await Snippet("my-snippet", { + zone: "example.com", // Zone ID, hostname, or Zone resource (required) + name: "my_snippet", // Snippet name (defaults to ${app}-${stage}-${id} with underscores) + + // Code source - provide exactly one of these: + script: ` // Inline JavaScript code +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set("X-Custom-Header", "value"); + return response; + } +} + `.trim(), + + // OR + entrypoint: "./src/snippets/auth.js", // Path to JavaScript file + + // Optional configuration + adopt: true, // Adopt existing snippet with same name (default: false) + + // Cloudflare API credentials (optional - uses environment variables by default) + accountId: "your-account-id", // Cloudflare account ID (default: CLOUDFLARE_ACCOUNT_ID env var) + apiToken: "your-api-token", // Cloudflare API token (default: CLOUDFLARE_API_TOKEN env var) +}); +``` + +**Important Notes:** + +⚠️ **Either `script` or `entrypoint` must be provided** (not both). Use `script` for inline code or `entrypoint` for file-based snippets. + +**Name Format Rules:** +- Only lowercase letters (a-z), numbers (0-9), and underscores (_) +- Max 50 characters +- Hyphens in default names are automatically converted to underscores + +:::caution +Snippets without SnippetRules never execute. Always create rules to activate your snippets. +::: + +## Execution Order + +Snippets execute **after** these Cloudflare features in this order: +1. Single Redirects +2. URL Rewrite Rules +3. Configuration Rules +4. Origin Rules +5. Bulk Redirects +6. Managed Transforms +7. Request Header Transform Rules +8. Cache Rules + +This means Snippets see requests after they've been processed by these rules. + +## Zone Specification + +Specify the zone where your snippet runs using any format: + +```ts +// Zone ID (fastest - no lookup needed) +await Snippet("snippet-1", { + zone: "023e105f4ecef8ad9ca31a8372d0c353", + name: "snippet-1", + script: "export default { async fetch(r) { return fetch(r); } }", +}); + +// Zone hostname (auto-resolved) +await Snippet("snippet-2", { + zone: "example.com", + name: "snippet-2", + script: "export default { async fetch(r) { return fetch(r); } }", +}); + +// Zone resource (Alchemy composition) +const zone = await Zone("main", { name: "example.com", type: "full" }); +await Snippet("snippet-3", { + zone, + name: "snippet-3", + script: "export default { async fetch(r) { return fetch(r); } }", +}); +``` + +:::note +Zone IDs (32-char hex strings) don't need API lookups. Hostnames are convenient but require an extra API call to resolve. Zone resources are Alchemy-native and compose well with other resources. +::: + +## Basic Snippet Examples + +### Add Security Headers + +```ts +const securityHeaders = await Snippet("security-headers", { + zone: "example.com", + name: "security-headers", + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + + return response; + } +} + `.trim(), +}); +``` + +### JWT Validation + +```ts +const jwtValidator = await Snippet("jwt-validator", { + zone: "example.com", + name: "jwt-validator", + script: ` +export default { + async fetch(request) { + const token = request.headers.get("Authorization")?.replace("Bearer ", ""); + + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + + // Validate JWT (simplified - use a proper JWT library in production) + try { + const parts = token.split("."); + if (parts.length !== 3) throw new Error("Invalid JWT format"); + + // In production, verify the signature + const payload = JSON.parse(atob(parts[1])); + + // Continue to origin + const response = await fetch(request); + response.headers.set("X-User-ID", payload.sub); + + return response; + } catch (error) { + return new Response("Invalid token", { status: 403 }); + } + } +} + `.trim(), +}); +``` + +### Custom Redirects + +```ts +const redirector = await Snippet("smart-redirects", { + zone: "example.com", + name: "smart-redirects", + script: ` +export default { + async fetch(request) { + const url = new URL(request.url); + + // Redirect old URLs to new location + if (url.pathname.startsWith("/old-blog/")) { + const newPath = url.pathname.replace("/old-blog/", "/blog/"); + return Response.redirect(url.origin + newPath, 301); + } + + // Mobile redirect + if (request.headers.get("User-Agent")?.includes("Mobile")) { + if (!url.hostname.startsWith("m.")) { + return Response.redirect("https://m." + url.hostname + url.pathname, 302); + } + } + + return fetch(request); + } +} + `.trim(), +}); +``` + +## Load from File + +Store your snippet code in a separate file for easier management: + +```ts +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const scriptContent = await readFile( + resolve("./src/snippets/auth-handler.js"), + "utf-8" +); + +const authSnippet = await Snippet("auth-handler", { + zone: "example.com", + name: "auth-handler", + script: scriptContent, +}); +``` + +**Snippet file:** `./src/snippets/auth-handler.js` +```js +export default { + async fetch(request) { + const token = request.headers.get("Authorization"); + if (!token) { + return new Response("Unauthorized", { status: 401 }); + } + return fetch(request); + } +} +``` + +## Performance Constraints + +Snippets have strict limits to maintain edge performance: + +| Limit | Value | +|-------|-------| +| Execution time | 5 ms | +| Memory | 2 MB | +| Total code size | 32 KB | +| Subrequests | 2-5 (by plan) | + +**Plan limits:** +- **Pro**: 25 snippets/zone, 2 subrequests +- **Business**: 50 snippets/zone, 3 subrequests +- **Enterprise**: 300 snippets/zone, 5 subrequests + +:::caution[Subrequest Chain] +When using `fetch` in a snippet, it's important to remember that `fetch` itself is a subrequest. If you need to make multiple subrequests, you must ensure you stay within the subrequest limit. +::: + +Keep your code lightweight: + +```ts +// ❌ Too slow - expensive computation +export default { + async fetch(request) { + const result = await expensiveQuery(); // Don't do this + return new Response(result); + } +} + +// ✅ Lightweight - fast headers and redirects +export default { + async fetch(request) { + if (request.url.includes("/admin")) { + return Response.redirect("/"); + } + return fetch(request); + } +} +``` + +## Adoption Pattern + +Already have snippets? Adopt them instead of failing: + +```ts +const existing = await Snippet("legacy-snippet", { + zone: "example.com", + name: "legacy-snippet", // Must match existing snippet name + script: "...", + adopt: true, // Use existing if found, don't error +}); +``` + +:::note +Adoption matches by exact snippet name. If a snippet with that name exists, it updates with the new configuration instead of failing. +::: + +## Validation & Error Handling + +Snippets validate name format before deployment: + +```ts +// ❌ Invalid - contains hyphens +await Snippet("bad-name", { + zone: "example.com", + name: "bad-name", // Error: invalid characters + script: "...", +}); + +// ✅ Valid - lowercase, letters, numbers, underscores +await Snippet("auth_handler", { + zone: "example.com", + name: "auth_handler", + script: "...", +}); +``` + +**Valid name format:** +- Lowercase letters (a-z) +- Numbers (0-9) +- Underscores (_) +- Max 50 characters + +## Binding to Workers + +Use snippets in Workers via environment references: + +```ts +// alchemy.run.ts +const authSnippet = await Snippet("auth-checker", { + zone: "example.com", + name: "auth-checker", + script: "export default { async fetch(r) { return fetch(r); } }", +}); + +export const worker = await Worker("api", { + entrypoint: "./src/api.ts", + bindings: { + SNIPPET_NAME: "auth-checker", // Reference by name + } +}); +``` + +Then access in your worker: + +```ts +// src/api.ts +export default { + async fetch(request: Request, env: typeof worker.Env) { + console.log(`Using snippet: ${env.SNIPPET_NAME}`); + return new Response("OK"); + } +} +``` + +## Working with Snippet Rules + +Snippets without rules do nothing. Use `SnippetRule` to control when they execute: + +```ts +import { Snippet, SnippetRule } from "alchemy/cloudflare"; + +// Create snippet +const snippet = await Snippet("cache-buster", { + zone: "example.com", + name: "cache-buster", + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set("Cache-Control", "no-cache"); + return response; + } +} + `.trim(), +}); + +// Attach execution rules +const rules = await SnippetRule("cache-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet, // Reference the snippet resource + description: "Bypass cache for API endpoints", + enabled: true, + }, + ], +}); +``` + +:::tip +See [SnippetRule](/providers/cloudflare/snippet-rule) for detailed rule configuration and execution control. +::: + +## Advanced Patterns + +### Conditional Response Caching + +Cache responses based on custom logic: + +```ts +const responseCacher = await Snippet("smart-cache", { + zone: "example.com", + name: "smart-cache", + script: ` +export default { + async fetch(request) { + const url = new URL(request.url); + const response = await fetch(request); + + // Don't cache error responses + if (response.status >= 400) { + return response; + } + + // Cache GET requests for 1 hour + if (request.method === "GET") { + response.headers.set("Cache-Control", "public, max-age=3600"); + } + + // Don't cache mutations + if (["POST", "PUT", "DELETE"].includes(request.method)) { + response.headers.set("Cache-Control", "no-cache"); + } + + return response; + } +} + `.trim(), +}); +``` + +### Request Transformation + +Modify incoming requests before they reach your origin: + +```ts +const requestTransformer = await Snippet("request-modifier", { + zone: "example.com", + name: "request-modifier", + script: ` +export default { + async fetch(request) { + // Normalize headers + const newRequest = new Request(request, { + headers: new Headers(request.headers), + }); + + newRequest.headers.set("X-Forwarded-Proto", "https"); + newRequest.headers.set("X-Forwarded-Host", request.headers.get("Host")); + + // Add request ID + newRequest.headers.set("X-Request-ID", crypto.randomUUID()); + + return fetch(newRequest); + } +} + `.trim(), +}); +``` + +### Request/Response Logging + +Log requests/responses for debugging: + +```ts +const logger = await Snippet("request-logger", { + zone: "example.com", + name: "request-logger", + script: ` +export default { + async fetch(request) { + const start = performance.now(); + const response = await fetch(request); + const duration = performance.now() - start; + + // Log to console (visible in Cloudflare Logs) + console.log(\`\${request.method} \${request.url} - \${response.status} (\${duration.toFixed(2)}ms)\`); + + return response; + } +} + `.trim(), +}); +``` + +## Debugging Snippets + +### Test with SnippetRule + +Use rule expressions to test snippet behavior on specific traffic: + +```ts +const testSnippet = await Snippet("debug-snippet", { + zone: "example.com", + name: "debug-snippet", + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + // Add debugging headers + response.headers.set("X-Snippet-Executed", "true"); + response.headers.set("X-Request-Time", new Date().toISOString()); + return response; + } +} + `.trim(), + adopt: true, +}); + +// Test on specific paths only +const rules = await SnippetRule("debug-rules", { + zone: "example.com", + rules: [ + { + expression: 'http.request.uri.path eq "/debug"', // Only test path + snippet: testSnippet, + enabled: true, + }, + ], +}); +``` + +### Performance Monitoring + +Monitor snippet execution in your logs: + +```ts +const monitored = await Snippet("perf-monitor", { + zone: "example.com", + name: "perf-monitor", + script: ` +export default { + async fetch(request) { + const start = Date.now(); + const response = await fetch(request); + const duration = Date.now() - start; + + if (duration > 100) { + console.warn(\`Slow response: \${duration}ms for \${request.url}\`); + } + + return response; + } +} + `.trim(), +}); +``` + +## Troubleshooting + +### Snippet Not Executing + +Snippet created but not running? Check the rules: + +```ts +// ❌ No rules = no execution +await Snippet("orphaned", { + zone: "example.com", + name: "orphaned", + script: "...", +}); + +// ✅ Attach execution rules +const snippet = await Snippet("active", { + zone: "example.com", + name: "active", + script: "...", +}); + +await SnippetRule("rules", { + zone: "example.com", + rules: [{ expression: "true", snippet }], // Now it runs +}); +``` + +### Performance Timeout (5ms Exceeded) + +Snippet execution takes too long: + +**Common causes:** +1. **Multiple subrequests** - Keep fetch calls under limit +2. **Complex regex** - Use simple string matching instead +3. **Large payloads** - Process in chunks, not all at once + +**Fix:** +```ts +// ❌ Too slow - multiple fetches +export default { + async fetch(request) { + const r1 = await fetch("https://api.example.com/1"); + const r2 = await fetch("https://api.example.com/2"); + return new Response(JSON.stringify({ r1, r2 })); + } +} + +// ✅ Fast - single operation +export default { + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/api") { + return new Response("cached", { headers: { "Cache-Control": "max-age=3600" } }); + } + return fetch(request); + } +} +``` + +### Memory Issues (2MB Exceeded) + +Snippet hits memory limit: + +**Common causes:** +1. **Large response buffering** - Stream instead of buffer +2. **Unnecessary data structures** - Keep code minimal +3. **Base64 encoding** - Use binary formats + +**Fix:** +```ts +// ❌ Buffers entire response +export default { + async fetch(request) { + const response = await fetch(request); + const body = await response.text(); // Loads entire body into memory + return new Response(body); + } +} + +// ✅ Streams response +export default { + async fetch(request) { + return fetch(request); // Streams directly to client + } +} +``` + +### Code Size Limit (32KB Exceeded) + +Your snippet is too large: + +**Common causes:** +1. **Inline data** - Move to separate file or KV store +2. **Unused libraries** - Trim dependencies +3. **Comments/formatting** - Minify before deploy + +**Fix:** +```ts +// Split large snippets into multiple smaller ones +const snippet1 = await Snippet("auth", { + zone: "example.com", + name: "auth", + script: "/* auth only */", +}); + +const snippet2 = await Snippet("headers", { + zone: "example.com", + name: "headers", + script: "/* headers only */", +}); + +await SnippetRule("orchestration", { + zone: "example.com", + rules: [ + { expression: 'http.request.uri.path eq "/auth"', snippet: snippet1 }, + { expression: "true", snippet: snippet2 }, + ], +}); +``` + +### Invalid Characters in Name + +Snippet name validation error: + +```ts +// ❌ Invalid characters +await Snippet("bad", { + zone: "example.com", + name: "my-snippet", // Error: hyphens not allowed + script: "...", +}); + +// ✅ Valid name +await Snippet("good", { + zone: "example.com", + name: "my_snippet", // Letters, numbers, underscores only + script: "...", +}); +``` + +## Best Practices + +1. **Keep snippets small** - One concern per snippet for easier testing +2. **Use rules to target traffic** - Don't waste CPU on requests that don't need the snippet +3. **Cache aggressively** - Let Cloudflare cache static responses +4. **Test edge cases** - Test with actual traffic patterns before production +5. **Monitor performance** - Track execution time and optimize slow paths +6. **Version your code** - Keep git history of snippet deployments +7. **Avoid sync operations** - Always use async/await for I/O +8. **Use secrets for sensitive data** - Don't hardcode API keys +9. **Plan availability** - Snippets are only available on Pro, Business, and Enterprise plans. +10. **No version rollback** - Cloudflare doesn't support versioning for Snippets. Test carefully before deploying changes. + +## Next steps + +- **Snippet Rules**: See [SnippetRule](/providers/cloudflare/snippet-rule) for execution control +- **Workers**: See [Worker](/providers/cloudflare/worker) for more complex edge logic +- **Zones**: See [Zone](/providers/cloudflare/zone) for zone management diff --git a/alchemy/src/cloudflare/index.ts b/alchemy/src/cloudflare/index.ts index e4dbf3cc6..f53f9ad5e 100644 --- a/alchemy/src/cloudflare/index.ts +++ b/alchemy/src/cloudflare/index.ts @@ -59,6 +59,8 @@ export * from "./secret-key.ts"; export * from "./secret-ref.ts"; export * from "./secret.ts"; export * from "./secrets-store.ts"; +export * from "./snippet.ts"; +export * from "./snippet-rule.ts"; export * from "./state.ts"; export * from "./sveltekit/sveltekit.ts"; export * from "./tanstack-start/tanstack-start.ts"; diff --git a/alchemy/src/cloudflare/snippet-rule.ts b/alchemy/src/cloudflare/snippet-rule.ts new file mode 100644 index 000000000..d877dc9b7 --- /dev/null +++ b/alchemy/src/cloudflare/snippet-rule.ts @@ -0,0 +1,354 @@ +import type { Context } from "../context.ts"; +import { Resource } from "../resource.ts"; +import { logger } from "../util/logger.ts"; +import { withExponentialBackoff } from "../util/retry.ts"; +import { CloudflareApiError } from "./api-error.ts"; +import { extractCloudflareResult } from "./api-response.ts"; +import { + createCloudflareApi, + type CloudflareApi, + type CloudflareApiOptions, +} from "./api.ts"; +import { type Snippet } from "./snippet.ts"; +import { findZoneForHostname, type Zone } from "./zone.ts"; + +/** + * Input format for snippet rule operations + * @internal + */ +export interface SnippetRuleInput { + expression: string; + snippetName: string; + description?: string; + enabled?: boolean; +} + +/** + * Cloudflare API response format for a snippet rule + * @internal + */ +export interface SnippetRuleResponse { + id: string; + expression: string; + snippet_name: string; + description?: string; + enabled: boolean; + last_updated: string; + version: string; +} + +/** + * Properties for creating or updating a batch of Snippet Rules + */ +export interface SnippetRuleProps extends CloudflareApiOptions { + /** + * The zone this rule batch belongs to + * Can be a zone ID (32-char hex), zone name/hostname (e.g. "example.com"), or a Zone resource + */ + zone: string | Zone; + + /** + * Array of rules to manage for this zone + * Rules are executed in the order they appear in this array + */ + rules: Array<{ + /** + * The expression defining which traffic will match the rule + * @example 'http.request.uri.path eq "/api"' + */ + expression: string; + + /** + * The snippet to execute (by name or Snippet resource) + */ + snippet: string | Snippet; + + /** + * Optional description of the rule + */ + description?: string; + + /** + * Whether the rule is enabled (default: true) + */ + enabled?: boolean; + + /** + * Optional ID for identifying this rule in the batch + * Used internally for adoption and updates + * @internal + */ + id?: string; + }>; + + /** + * Whether to adopt existing rules matching the same expressions/snippets + * @default false + */ + adopt?: boolean; +} + +/** + * A Snippet Rule batch resource + */ +export type SnippetRule = Omit & { + /** + * The identifier for this rule batch resource + */ + id: string; + + /** + * The zone ID + */ + zoneId: string; + + /** + * Rules managed by this resource + */ + rules: Array<{ + /** + * The ID of the rule + */ + ruleId: string; + + /** + * The expression for the rule + */ + expression: string; + + /** + * The snippet name + */ + snippetName: string; + + /** + * Description of the rule + */ + description?: string; + + /** + * Whether the rule is enabled + */ + enabled: boolean; + + /** + * Last updated timestamp + */ + lastUpdated: Date; + }>; + + /** + * Resource type identifier + * @internal + */ + type: "snippet-rule"; +}; + +/** + * Manages a batch of Snippet Rules for a zone + * + * The SnippetRule resource manages all snippet rules in a zone as a cohesive batch. + * Rules are executed in the order they appear in the rules array. This resource + * uses the batch update pattern for efficiency and atomic consistency. + * + * @example + * // Create a batch of rules with explicit order + * const rules = await SnippetRule("my-rules", { + * zone: "example.com", + * rules: [ + * { + * expression: 'http.request.uri.path eq "/api"', + * snippet: apiSnippet, + * description: "API endpoint handler", + * }, + * { + * expression: 'http.request.uri.path eq "/admin"', + * snippet: adminSnippet, + * description: "Admin panel handler", + * enabled: false, + * } + * ] + * }); + * + * @example + * // Update rules maintaining explicit order + * const updated = await SnippetRule("my-rules", { + * zone: "example.com", + * rules: [ + * // New first rule + * { + * expression: 'http.request.uri.path eq "/health"', + * snippet: healthSnippet, + * }, + * // Existing rules follow + * { + * id: previousRuleId, + * expression: 'http.request.uri.path eq "/api"', + * snippet: apiSnippet, + * } + * ] + * }); + */ +export const SnippetRule = Resource( + "cloudflare::SnippetRule", + async function ( + this: Context, + id: string, + props: SnippetRuleProps, + ): Promise { + const api = await createCloudflareApi(props); + let zoneId: string; + if (this.output?.zoneId) { + zoneId = this.output.zoneId; + } else if (typeof props.zone === "string") { + zoneId = props.zone.includes(".") + ? (await findZoneForHostname(api, props.zone)).zoneId + : props.zone; + } else { + zoneId = props.zone.id; + } + + if (this.phase === "delete") { + await deleteSnippetRules(api, zoneId); + return this.destroy(); + } + + const seenRuleDefinitions = new Set(); + for (const rule of props.rules) { + const key = `${rule.expression}:${ + typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name + }`; + if (seenRuleDefinitions.has(key)) { + throw new Error( + `Duplicate rule found: expression="${rule.expression}" with snippet="${ + typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name + }"`, + ); + } + seenRuleDefinitions.add(key); + } + + const existingRules = await listSnippetRules(api, zoneId); + const existingByKey = new Map( + existingRules.map((r) => [`${r.expression}:${r.snippet_name}`, r]), + ); + const apiRules: Array = []; + + for (const rule of props.rules) { + const snippetName = + typeof rule.snippet === "string" ? rule.snippet : rule.snippet.name; + const key = `${rule.expression}:${snippetName}`; + const existing = existingByKey.get(key); + + if (rule.id || existing) { + apiRules.push({ + id: rule.id || existing?.id, + expression: rule.expression, + snippetName, + description: rule.description, + enabled: rule.enabled ?? true, + }); + } else { + apiRules.push({ + expression: rule.expression, + snippetName, + description: rule.description, + enabled: rule.enabled ?? true, + }); + } + } + + const result = await withExponentialBackoff( + async () => updateSnippetRules(api, zoneId, apiRules), + (error: CloudflareApiError) => { + const shouldRetry = error.errorData?.some( + (e: any) => + e.code === 1002 || + e.message?.includes("doesn't exist") || + e.message?.includes("not found"), + ); + if (shouldRetry) { + logger.warn( + `Snippet rules update encountered error, retrying due to propagation delay: ${error.message}`, + ); + } + return shouldRetry; + }, + 20, + 100, + ); + + return { + id, + zoneId, + rules: result.map((r) => ({ + ruleId: r.id, + expression: r.expression, + snippetName: r.snippet_name, + description: r.description, + enabled: r.enabled, + lastUpdated: new Date(r.last_updated), + })), + type: "snippet-rule", + }; + }, +); + +/** + * List all snippet rules in a zone + * @internal + */ +export async function listSnippetRules( + api: CloudflareApi, + zoneId: string, +): Promise { + const result = await extractCloudflareResult( + `list snippet rules in zone "${zoneId}"`, + api.get(`/zones/${zoneId}/snippets/snippet_rules`), + ); + + return result ?? []; +} + +/** + * Update snippet rules in a zone (replaces all rules) + * @internal + */ +export async function updateSnippetRules( + api: CloudflareApi, + zoneId: string, + rules: Array, +): Promise { + const requestBody = { + rules: rules.map((rule) => ({ + ...(rule.id && { id: rule.id }), + expression: rule.expression, + snippet_name: rule.snippetName, + description: rule.description, + enabled: rule.enabled ?? true, + })), + }; + + const result = await extractCloudflareResult( + `update snippet rules in zone "${zoneId}"`, + api.put(`/zones/${zoneId}/snippets/snippet_rules`, requestBody), + ); + + return result ?? []; +} + +/** + * Delete all snippet rules in a zone + * @internal + */ +export async function deleteSnippetRules( + api: CloudflareApi, + zoneId: string, +): Promise { + try { + await api.delete(`/zones/${zoneId}/snippets/snippet_rules`); + } catch (error) { + logger.error(`Error deleting snippet rules in zone ${zoneId}:`, error); + throw error; + } +} diff --git a/alchemy/src/cloudflare/snippet.ts b/alchemy/src/cloudflare/snippet.ts new file mode 100644 index 000000000..aa15add8a --- /dev/null +++ b/alchemy/src/cloudflare/snippet.ts @@ -0,0 +1,424 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { Context } from "../context.ts"; +import { Resource, ResourceKind } from "../resource.ts"; +import { handleApiError } from "./api-error.ts"; +import { extractCloudflareResult } from "./api-response.ts"; +import { + createCloudflareApi, + type CloudflareApi, + type CloudflareApiOptions, +} from "./api.ts"; +import { findZoneForHostname, type Zone } from "./zone.ts"; + +/** + * Properties for creating or updating a Snippet + */ +export interface SnippetProps extends CloudflareApiOptions { + /** + * The zone this snippet belongs to + * Can be a zone ID (32-char hex), zone name/hostname (e.g. "example.com"), or a Zone resource + */ + zone: string | Zone; + + /** + * The identifying name of the snippet + * Only lowercase letters (a-z), numbers (0-9), and underscores (_) are allowed + * + * @default ${app}-${stage}-${id} (with dashes converted to underscores) + */ + name?: string; + + /** + * The JavaScript code content of the snippet (inline) + * Either script or entrypoint must be provided + */ + script?: undefined | string; + + /** + * Path to the JavaScript file containing the snippet code + * Either script or entrypoint must be provided + */ + entrypoint?: string; + + /** + * Whether to adopt an existing snippet + * @default false + */ + adopt?: boolean; +} + +/** + * Output returned after Snippet creation/update. + * IMPORTANT: The type name MUST match the exported resource name. + */ +export type Snippet = Omit< + SnippetProps, + "zone" | "script" | "entrypoint" | "adopt" | "accountId" | "apiToken" +> & { + /** + * The ID of the resource + */ + id: string; + + /** + * The identifying name of the snippet + */ + name: string; + + /** + * The snippet name (used for update/delete operations) + * @internal + */ + snippetId: string; + + /** + * Zone ID for the domain. + */ + zoneId: string; + + /** + * When the snippet was created + */ + createdOn: Date; + + /** + * When the snippet was last modified + */ + modifiedOn: Date; + + /** + * Resource type identifier + * @internal + */ + type: "snippet"; +}; + +/** + * Type guard for Snippet resource + */ +export function isSnippet(resource: any): resource is Snippet { + return resource?.[ResourceKind] === "cloudflare::Snippet"; +} + +/** + * Creates and manages Cloudflare Snippets for executing custom JavaScript at the edge. + * + * Snippets allow you to run custom JavaScript code on Cloudflare's edge network, + * enabling modifications to requests and responses for your zone. + * + * After creating Snippets, use the SnippetRule resource to define when each snippet should execute. + * A Snippet without any associated rules will not be executed. + * + * @example + * // Create a basic snippet with inline script that adds a custom header: + * const headerSnippet = await Snippet("custom-header", { + * zone: myZone, + * script: ` + * export default { + * async fetch(request) { + * const response = await fetch(request); + * response.headers.set('X-Custom-Header', 'Hello from Snippet'); + * return response; + * } + * } + * ` + * }); + * + * @example + * // Create a snippet from a file entrypoint: + * const fileSnippet = await Snippet("my-snippet", { + * zone: "example.com", + * entrypoint: "./src/snippets/header-modifier.js" + * }); + * + * @example + * // Create a snippet that modifies request URLs with auto-generated name: + * const urlRewriteSnippet = await Snippet("url-rewrite", { + * zone: "example.com", + * script: ` + * export default { + * async fetch(request) { + * const url = new URL(request.url); + * if (url.pathname.startsWith('/api/v1/')) { + * url.pathname = url.pathname.replace('/api/v1/', '/api/v2/'); + * } + * return fetch(new Request(url, request)); + * } + * } + * ` + * }); + * + * @example + * // Create a snippet for A/B testing with explicit name: + * const abTestSnippet = await Snippet("ab-test", { + * zone: myZone, + * name: "ab_testing", + * script: ` + * export default { + * async fetch(request) { + * const variant = Math.random() < 0.5 ? 'A' : 'B'; + * const response = await fetch(request); + * response.headers.set('X-AB-Variant', variant); + * return response; + * } + * } + * ` + * }); + * + * @example + * // Create a snippet with associated execution rules: + * import { SnippetRule } from "./snippet-rule.ts"; + * + * const apiSnippet = await Snippet("api-processor", { + * zone: myZone, + * script: ` + * export default { + * async fetch(request) { + * return fetch(request); + * } + * } + * ` + * }); + * + * // Create a rule to execute this snippet only on /api paths + * const apiRule = await SnippetRule("api-rule", { + * zone: myZone, + * snippet: apiSnippet, + * expression: 'http.request.uri.path starts_with "/api"', + * enabled: true + * }); + * + * @see https://developers.cloudflare.com/rules/snippets/ + * @see SnippetRule - Use this resource to define when snippets should execute + */ +export const Snippet = Resource( + "cloudflare::Snippet", + async function ( + this: Context, + id: string, + props: SnippetProps, + ): Promise { + const name = + props.name ?? this.output?.name ?? this.scope.createPhysicalName(id, "_"); + + //* Early validation to prevent HTTP errors + validateSnippetName(name); + + const api = await createCloudflareApi(props); + let zoneId: string; + if (this.output?.zoneId) { + zoneId = this.output.zoneId; + } else if (typeof props.zone === "string") { + zoneId = props.zone.includes(".") + ? (await findZoneForHostname(api, props.zone)).zoneId + : props.zone; + } else { + zoneId = props.zone.id; + } + const snippetId = this.output?.snippetId || name; + const adopt = props.adopt ?? this.scope.adopt; + + if (this.phase === "delete") { + await deleteSnippet(api, zoneId, snippetId); + return this.destroy(); + } + + const content = await getScriptContent(props); + const exists = await snippetExists(api, zoneId, name); + + if (this.phase === "create" && exists && !adopt) { + throw new Error( + `Snippet "${name}" already exists. Use adopt: true to adopt it.`, + ); + } + + await createOrUpdateSnippet(api, zoneId, name, content); + + const result = await getSnippet(api, zoneId, name); + + return { + id, + name, + snippetId: name, + zoneId, + createdOn: new Date(result.created_on), + modifiedOn: new Date(result.modified_on), + type: "snippet", + }; + }, +); + +/** + * Get script content from either inline script or file entrypoint + * @internal + */ +async function getScriptContent(props: SnippetProps): Promise { + if ("script" in props && props.script) { + return props.script; + } + if ("entrypoint" in props && props.entrypoint) { + const filePath = resolve(props.entrypoint); + return await readFile(filePath, "utf-8"); + } + throw new Error("Either 'script' or 'entrypoint' must be provided"); +} + +/** + * Cloudflare API response format for a snippet + * @internal + */ +interface SnippetResponse { + created_on: string; + snippet_name: string; + modified_on: string; +} + +/** + * Create or update a snippet + * @internal + */ +export async function createOrUpdateSnippet( + api: CloudflareApi, + zoneId: string, + snippetName: string, + content: string, +): Promise { + const body = new FormData(); + body.append( + "files", + new Blob([content], { type: "application/javascript" }), + "snippet.js", + ); + body.append( + "metadata", + new Blob([JSON.stringify({ main_module: "snippet.js" })], { + type: "application/json", + }), + ); + + const snippetResponse = await api.put( + `/zones/${zoneId}/snippets/${snippetName}`, + body, + ); + + if (!snippetResponse.ok) { + await handleApiError( + snippetResponse, + "create or update", + "snippet", + snippetName, + ); + } +} + +/** + * Check if a snippet exists + * @internal + */ +export async function snippetExists( + api: CloudflareApi, + zoneId: string, + snippetName: string, +): Promise { + const getResponse = await api.get(`/zones/${zoneId}/snippets/${snippetName}`); + return getResponse.ok; +} + +/** + * Get a snippet by name + * @internal + */ +export async function getSnippet( + api: CloudflareApi, + zoneId: string, + snippetName: string, +): Promise { + return await extractCloudflareResult( + `get snippet "${snippetName}"`, + api.get(`/zones/${zoneId}/snippets/${snippetName}`), + ); +} + +/** + * Get the content of a snippet + * @internal + */ +export async function getSnippetContent( + api: CloudflareApi, + zoneId: string, + snippetName: string, +): Promise { + const response = await api.get( + `/zones/${zoneId}/snippets/${snippetName}/content`, + ); + + if (!response.ok) { + throw await handleApiError(response, "get", "snippet content", snippetName); + } + + return await response.text(); +} + +/** + * List all snippets in a zone + * @internal + */ +export async function listSnippets( + api: CloudflareApi, + zoneId: string, +): Promise { + return await extractCloudflareResult( + `list snippets in zone "${zoneId}"`, + api.get(`/zones/${zoneId}/snippets`), + ); +} + +/** + * Delete a snippet from Cloudflare + * @internal + */ +export async function deleteSnippet( + api: CloudflareApi, + zoneId: string, + snippetName: string, +): Promise { + const deleteResponse = await api.delete( + `/zones/${zoneId}/snippets/${snippetName}`, + ); + + if (!deleteResponse.ok && deleteResponse.status !== 404) { + if (deleteResponse.status === 400) { + const errorBody = await deleteResponse.text(); + if (errorBody.includes("requested snippet not found")) { + return; + } + } + await handleApiError(deleteResponse, "delete", "snippet", snippetName); + } +} + +/** + * Validates that a snippet name meets Cloudflare requirements due to strong restrictions on the name. + * Cloudflare Snippets only allow lowercase letters (a-z), numbers (0-9), and underscores (_) + * + * @throws Error if the name does not meet Cloudflare snippet requirements + * @internal + */ +export function validateSnippetName(name: string): boolean { + if (!name || name.trim().length === 0) { + throw new Error("Snippet name cannot be empty"); + } + + if (name.length > 255) { + throw new Error("Snippet name cannot exceed 255 characters"); + } + + if (!/^[a-z0-9_]+$/.test(name)) { + throw new Error( + "Snippet name must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid characters found.", + ); + } + + return true; +} diff --git a/alchemy/test/cloudflare/snippet-rule.test.ts b/alchemy/test/cloudflare/snippet-rule.test.ts new file mode 100644 index 000000000..017173c2d --- /dev/null +++ b/alchemy/test/cloudflare/snippet-rule.test.ts @@ -0,0 +1,409 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { createCloudflareApi } from "../../src/cloudflare/api.ts"; +import { + listSnippetRules, + SnippetRule, +} from "../../src/cloudflare/snippet-rule.ts"; +import { Snippet } from "../../src/cloudflare/snippet.ts"; +import { findZoneForHostname } from "../../src/cloudflare/zone.ts"; +import { destroy } from "../../src/destroy.ts"; +import "../../src/test/vitest.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import { createSnippetName } from "./snippet-test-util.ts"; + +const ZONE_NAME = process.env.TEST_ZONE ?? process.env.ALCHEMY_TEST_DOMAIN!; +const api = await createCloudflareApi(); +const zoneId = (await findZoneForHostname(api, ZONE_NAME)).zoneId; +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +describe.sequential("SnippetRule Batch Resource", () => { + test("create and delete batch of rules with explicit order", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_batch_snippet_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_batch_snippet_2`); + const snippet3Name = createSnippetName(`${BRANCH_PREFIX}_batch_snippet_3`); + const batchId = `${BRANCH_PREFIX}_batch_rules`; + + let snippet1: Snippet | undefined; + let snippet2: Snippet | undefined; + let snippet3: Snippet | undefined; + let batch: SnippetRule | undefined; + + try { + snippet1 = await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: + "export default { async fetch(r) { return new Response('1'); } }", + adopt: true, + }); + + snippet2 = await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: + "export default { async fetch(r) { return new Response('2'); } }", + adopt: true, + }); + + snippet3 = await Snippet(snippet3Name, { + zone: zoneId, + name: snippet3Name, + script: + "export default { async fetch(r) { return new Response('3'); } }", + adopt: true, + }); + + batch = await SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + expression: 'http.request.uri.path eq "/first"', + snippet: snippet1, + description: "First handler - executes first", + }, + { + expression: 'http.request.uri.path eq "/second"', + snippet: snippet2, + description: "Second handler - executes second", + }, + { + expression: 'http.request.uri.path eq "/third"', + snippet: snippet3, + description: "Third handler - executes last", + enabled: false, + }, + ], + }); + + expect(batch.id).toEqual(batchId); + expect(batch.rules).toHaveLength(3); + expect(batch.rules[0].expression).toEqual( + 'http.request.uri.path eq "/first"', + ); + expect(batch.rules[0].snippetName).toEqual(snippet1Name); + expect(batch.rules[1].expression).toEqual( + 'http.request.uri.path eq "/second"', + ); + expect(batch.rules[1].snippetName).toEqual(snippet2Name); + expect(batch.rules[2].expression).toEqual( + 'http.request.uri.path eq "/third"', + ); + expect(batch.rules[2].enabled).toBe(false); + + // Verify rules were created in API + const apiRules = await listSnippetRules(api, zoneId); + const ourRules = apiRules.filter((r) => + [snippet1Name, snippet2Name, snippet3Name].includes(r.snippet_name), + ); + expect(ourRules).toHaveLength(3); + + batch = await SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + id: batch.rules[2].ruleId, + expression: 'http.request.uri.path eq "/third"', + snippet: snippet3, + description: "Third handler - now first", + enabled: true, + }, + { + id: batch.rules[0].ruleId, + expression: 'http.request.uri.path eq "/first"', + snippet: snippet1, + description: "First handler - now second", + }, + { + id: batch.rules[1].ruleId, + expression: 'http.request.uri.path eq "/second"', + snippet: snippet2, + description: "Second handler - now third", + }, + ], + }); + + expect(batch.rules[0].expression).toEqual( + 'http.request.uri.path eq "/third"', + ); + expect(batch.rules[0].enabled).toBe(true); + expect(batch.rules[1].expression).toEqual( + 'http.request.uri.path eq "/first"', + ); + expect(batch.rules[2].expression).toEqual( + 'http.request.uri.path eq "/second"', + ); + } finally { + await destroy(scope); + + // Verify all rules were deleted + const finalRules = await listSnippetRules(api, zoneId); + const remainingOurRules = finalRules.filter((r) => + [snippet1Name, snippet2Name, snippet3Name].includes(r.snippet_name), + ); + expect(remainingOurRules).toHaveLength(0); + } + }, 180000); + + test("handles empty rules array", async (scope) => { + const batchId = `${BRANCH_PREFIX}_empty_batch`; + + try { + const batch = await SnippetRule(batchId, { + zone: zoneId, + rules: [], + }); + + expect(batch.id).toEqual(batchId); + expect(batch.rules).toHaveLength(0); + } finally { + await destroy(scope); + } + }); + + test("throws error for duplicate rule definitions", async (scope) => { + const snippetName = createSnippetName(`${BRANCH_PREFIX}_dup_snippet`); + const batchId = `${BRANCH_PREFIX}_dup_batch`; + + let snippet: Snippet | undefined; + + try { + snippet = await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: "export default { async fetch(r) { return fetch(r); } }", + adopt: true, + }); + + await expect( + SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + expression: 'http.request.uri.path eq "/api"', + snippet: snippet, + }, + { + expression: 'http.request.uri.path eq "/api"', + snippet: snippet, + }, + ], + }), + ).rejects.toThrow(/Duplicate rule found/); + } finally { + await destroy(scope); + } + }); + + test("adoption prevents conflicts", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_adopt_snippet_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_adopt_snippet_2`); + const batchId = `${BRANCH_PREFIX}_adopt_batch`; + let snippet1: Snippet | undefined; + let snippet2: Snippet | undefined; + + try { + snippet1 = await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: + "export default { async fetch(r) { return new Response('1'); } }", + adopt: true, + }); + + snippet2 = await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: + "export default { async fetch(r) { return new Response('2'); } }", + adopt: true, + }); + + const batch1 = await SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + expression: 'http.request.uri.path eq "/first"', + snippet: snippet1, + }, + ], + }); + + expect(batch1.rules).toHaveLength(1); + const firstRuleId = batch1.rules[0].ruleId; + const batch2 = await SnippetRule(batchId, { + zone: zoneId, + adopt: true, + rules: [ + { + id: firstRuleId, + expression: 'http.request.uri.path eq "/first"', + snippet: snippet1, + }, + { + expression: 'http.request.uri.path eq "/second"', + snippet: snippet2, + }, + ], + }); + + expect(batch2.rules).toHaveLength(2); + expect(batch2.rules[0].ruleId).toEqual(firstRuleId); + expect(batch2.rules[1].snippetName).toEqual(snippet2Name); + } finally { + await destroy(scope); + } + }); + + test("performance - single API call for batch", async (scope) => { + const snippets: Snippet[] = []; + const snippetNames: string[] = []; + const batchId = `${BRANCH_PREFIX}_perf_batch`; + + try { + const snippetPromises = Array.from({ length: 10 }, (_, i) => { + const name = createSnippetName(`${BRANCH_PREFIX}_perf_snippet_${i}`); + snippetNames.push(name); + return Snippet(name, { + zone: zoneId, + name, + script: `export default { async fetch(r) { return new Response('${i}'); } }`, + adopt: true, + }); + }); + + const createdSnippets = await Promise.all(snippetPromises); + snippets.push(...createdSnippets); + + // Create batch with all 10 rules in one call + const batch = await SnippetRule(batchId, { + zone: zoneId, + rules: snippets.map((snippet, i) => ({ + expression: `http.request.uri.path eq "/path${i}"`, + snippet, + description: `Rule ${i}`, + })), + }); + + expect(batch.rules).toHaveLength(10); + + // Verify all rules exist + const apiRules = await listSnippetRules(api, zoneId); + const ourRules = apiRules.filter((r) => + snippetNames.includes(r.snippet_name), + ); + expect(ourRules).toHaveLength(10); + } finally { + await destroy(scope); + } + }, 120000); + + test("update using snippet string references", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_string_ref_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_string_ref_2`); + const batchId = `${BRANCH_PREFIX}_string_batch`; + + try { + await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: + "export default { async fetch(r) { return new Response('1'); } }", + adopt: true, + }); + + await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: + "export default { async fetch(r) { return new Response('2'); } }", + adopt: true, + }); + + const batch = await SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + expression: 'http.request.uri.path eq "/path1"', + snippet: snippet1Name, + }, + { + expression: 'http.request.uri.path eq "/path2"', + snippet: snippet2Name, + }, + ], + }); + + expect(batch.rules).toHaveLength(2); + expect(batch.rules[0].snippetName).toEqual(snippet1Name); + expect(batch.rules[1].snippetName).toEqual(snippet2Name); + } finally { + await destroy(scope); + } + }); + + test("rule execution order determined by array position", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_order_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_order_2`); + const snippet3Name = createSnippetName(`${BRANCH_PREFIX}_order_3`); + const batchId = `${BRANCH_PREFIX}_order_batch`; + let snippet1: Snippet | undefined; + let snippet2: Snippet | undefined; + let snippet3: Snippet | undefined; + + try { + snippet1 = await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: + "export default { async fetch(r) { return new Response('1'); } }", + adopt: true, + }); + + snippet2 = await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: + "export default { async fetch(r) { return new Response('2'); } }", + adopt: true, + }); + + snippet3 = await Snippet(snippet3Name, { + zone: zoneId, + name: snippet3Name, + script: + "export default { async fetch(r) { return new Response('3'); } }", + adopt: true, + }); + + const batch = await SnippetRule(batchId, { + zone: zoneId, + rules: [ + { + expression: "true", + snippet: snippet1, + description: "Executes first", + }, + { + expression: "true", + snippet: snippet2, + description: "Executes second", + }, + { + expression: "true", + snippet: snippet3, + description: "Executes third", + }, + ], + }); + + expect(batch.rules[0].snippetName).toEqual(snippet1Name); + expect(batch.rules[1].snippetName).toEqual(snippet2Name); + expect(batch.rules[2].snippetName).toEqual(snippet3Name); + } finally { + await destroy(scope); + } + }); +}); diff --git a/alchemy/test/cloudflare/snippet-test-util.ts b/alchemy/test/cloudflare/snippet-test-util.ts new file mode 100644 index 000000000..91c075168 --- /dev/null +++ b/alchemy/test/cloudflare/snippet-test-util.ts @@ -0,0 +1,11 @@ +/** + * Utility functions for snippet tests + */ + +/** + * Create a valid snippet name from a base string + * Cloudflare snippet names must contain only lowercase letters, numbers, and underscores + */ +export function createSnippetName(base: string): string { + return base.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); +} diff --git a/alchemy/test/cloudflare/snippet.test.ts b/alchemy/test/cloudflare/snippet.test.ts new file mode 100644 index 000000000..579ce56c3 --- /dev/null +++ b/alchemy/test/cloudflare/snippet.test.ts @@ -0,0 +1,319 @@ +import { describe, expect } from "vitest"; +import { alchemy } from "../../src/alchemy.ts"; +import { createCloudflareApi } from "../../src/cloudflare/api.ts"; +import { + deleteSnippetRules, + updateSnippetRules, + type SnippetRuleInput, +} from "../../src/cloudflare/snippet-rule.ts"; +import { + Snippet, + deleteSnippet, + getSnippet, + getSnippetContent, + listSnippets, +} from "../../src/cloudflare/snippet.ts"; +import { findZoneForHostname } from "../../src/cloudflare/zone.ts"; +import { destroy } from "../../src/destroy.ts"; +import "../../src/test/vitest.ts"; +import { BRANCH_PREFIX } from "../util.ts"; +import { createSnippetName } from "./snippet-test-util.ts"; + +const ZONE_NAME = process.env.TEST_ZONE ?? process.env.ALCHEMY_TEST_DOMAIN!; +const api = await createCloudflareApi(); +const zoneId = (await findZoneForHostname(api, ZONE_NAME)).zoneId; +const test = alchemy.test(import.meta, { + prefix: BRANCH_PREFIX, +}); + +async function verifySnippetExists(snippetName: string): Promise { + const snippet = await getSnippet(api, zoneId, snippetName); + expect(snippet.snippet_name).toEqual(snippetName); +} + +async function verifySnippetDeleted(snippetName: string): Promise { + try { + await getSnippet(api, zoneId, snippetName); + throw new Error("Snippet should have been deleted"); + } catch (error) { + expect(error).toBeTruthy(); + } +} + +describe("Snippet Resource", () => { + test("create, update, and delete snippet", async (scope) => { + const snippetName = createSnippetName(`${BRANCH_PREFIX}_test_snippet`); + let snippet: Snippet | undefined; + + try { + snippet = await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Test-Header', 'Hello from Snippet'); + return response; + } +} + `.trim(), + }); + + expect(snippet.id).toEqual(snippetName); + expect(snippet.name).toEqual(snippetName); + expect(snippet.zoneId).toEqual(zoneId); + expect(snippet.createdOn).toBeInstanceOf(Date); + expect(snippet.modifiedOn).toBeInstanceOf(Date); + + await verifySnippetExists(snippetName); + + const content = await getSnippetContent(api, zoneId, snippetName); + expect(content).toContain("X-Test-Header"); + expect(content).toContain("Hello from Snippet"); + + snippet = await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Test-Header', 'Updated Content'); + response.headers.set('X-Version', '2.0'); + return response; + } +} + `.trim(), + }); + + expect(snippet.name).toEqual(snippetName); + + const updatedContent = await getSnippetContent(api, zoneId, snippetName); + expect(updatedContent).toContain("Updated Content"); + expect(updatedContent).toContain("X-Version"); + + const snippets = await listSnippets(api, zoneId); + expect(snippets.length).toBeGreaterThan(0); + const foundSnippet = snippets.find((s) => s.snippet_name === snippetName); + expect(foundSnippet).toBeTruthy(); + } finally { + await destroy(scope); + + if (snippet) { + await verifySnippetDeleted(snippet.name); + } + } + }); + + test("snippet with rules", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_rule_snippet_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_rule_snippet_2`); + let snippet1: Snippet | undefined; + let snippet2: Snippet | undefined; + + try { + snippet1 = await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Snippet', '1'); + return response; + } +} + `.trim(), + }); + + snippet2 = await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Snippet', '2'); + return response; + } +} + `.trim(), + }); + + expect(snippet1.name).toEqual(snippet1Name); + expect(snippet2.name).toEqual(snippet2Name); + + const rules: SnippetRuleInput[] = [ + { + expression: 'http.request.uri.path eq "/api"', + snippetName: snippet1Name, + description: "Apply snippet 1 to /api paths", + enabled: true, + }, + { + expression: 'http.request.uri.path eq "/admin"', + snippetName: snippet2Name, + description: "Apply snippet 2 to /admin paths", + enabled: true, + }, + ]; + + const createdRules = await updateSnippetRules(api, zoneId, rules); + expect(createdRules.length).toEqual(2); + expect(createdRules[0].snippet_name).toEqual(snippet1Name); + expect(createdRules[1].snippet_name).toEqual(snippet2Name); + + await deleteSnippetRules(api, zoneId); + } finally { + await destroy(scope); + } + }); + + test("delete snippet explicitly", async (scope) => { + const snippetName = createSnippetName(`${BRANCH_PREFIX}_delete_test`); + + try { + const snippet = await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + return fetch(request); + } +} + `.trim(), + }); + + expect(snippet.name).toEqual(snippetName); + + await verifySnippetExists(snippetName); + + await deleteSnippet(api, zoneId, snippetName); + + await verifySnippetDeleted(snippetName); + } finally { + await destroy(scope); + } + }); + + test("list snippets in zone", async (scope) => { + const snippet1Name = createSnippetName(`${BRANCH_PREFIX}_list_1`); + const snippet2Name = createSnippetName(`${BRANCH_PREFIX}_list_2`); + + try { + await Snippet(snippet1Name, { + zone: zoneId, + name: snippet1Name, + script: + "export default { async fetch(request) { return fetch(request); } }", + adopt: true, + }); + + await Snippet(snippet2Name, { + zone: zoneId, + name: snippet2Name, + script: + "export default { async fetch(request) { return fetch(request); } }", + adopt: true, + }); + + const snippets = await listSnippets(api, zoneId); + expect(snippets.length).toBeGreaterThanOrEqual(2); + + const snippet1 = snippets.find((s) => s.snippet_name === snippet1Name); + const snippet2 = snippets.find((s) => s.snippet_name === snippet2Name); + + expect(snippet1).toBeTruthy(); + expect(snippet2).toBeTruthy(); + } finally { + await destroy(scope); + } + }); + + test("adopt existing snippet", async (scope) => { + const snippetName = createSnippetName(`${BRANCH_PREFIX}_adopt_test`); + + try { + const snippet1 = await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Version', '1.0'); + return response; + } +} + `.trim(), + adopt: true, + }); + + expect(snippet1.name).toEqual(snippetName); + + try { + await Snippet(`${snippetName}-2`, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + return fetch(request); + } +} + `.trim(), + }); + throw new Error( + "Should have thrown error for existing snippet without adopt", + ); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("already exists"); + expect((error as Error).message).toContain("adopt: true"); + } + + const snippet2 = await Snippet(`${snippetName}-adopted`, { + zone: zoneId, + name: snippetName, + script: ` +export default { + async fetch(request) { + const response = await fetch(request); + response.headers.set('X-Version', '2.0'); + return response; + } +} + `.trim(), + adopt: true, + }); + + expect(snippet2.name).toEqual(snippetName); + + const adoptedContent = await getSnippetContent(api, zoneId, snippetName); + expect(adoptedContent).toContain("X-Version"); + expect(adoptedContent).toContain("2.0"); + } finally { + await destroy(scope); + } + }); + + test("validate snippet name throws error for invalid characters", async () => { + const snippetName = "A@B-1.2"; + try { + await Snippet(snippetName, { + zone: zoneId, + name: snippetName, + script: + "export default { async fetch(request) { return fetch(request); } }", + }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Snippet name must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid characters found.", + ); + } + // Should never need to destroy scope since validation happens early and failure results in Snippet HTTP error + }); +});