From b0e1f6dfe3552c97a6db344f89e03850aed5b2c8 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:24:18 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 12 + README.md | 3 + SKILL.md | 563 ++++++++++++++++++ plugin.lock.json | 129 ++++ references/api-reference.md | 403 +++++++++++++ references/direct-upload-complete-workflow.md | 393 ++++++++++++ references/format-optimization.md | 359 +++++++++++ references/responsive-images-patterns.md | 274 +++++++++ references/signed-urls-guide.md | 350 +++++++++++ references/top-errors.md | 476 +++++++++++++++ references/transformation-options.md | 337 +++++++++++ references/variants-guide.md | 264 ++++++++ scripts/check-versions.sh | 106 ++++ templates/batch-upload.ts | 308 ++++++++++ templates/direct-creator-upload-backend.ts | 208 +++++++ templates/direct-creator-upload-frontend.html | 294 +++++++++ templates/package.json | 16 + templates/responsive-images-srcset.html | 251 ++++++++ templates/signed-urls-generation.ts | 255 ++++++++ templates/transform-via-url.ts | 225 +++++++ templates/transform-via-workers.ts | 331 ++++++++++ templates/upload-api-basic.ts | 150 +++++ templates/upload-via-url.ts | 207 +++++++ templates/variants-management.ts | 312 ++++++++++ templates/wrangler-images-binding.jsonc | 41 ++ 25 files changed, 6267 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 plugin.lock.json create mode 100644 references/api-reference.md create mode 100644 references/direct-upload-complete-workflow.md create mode 100644 references/format-optimization.md create mode 100644 references/responsive-images-patterns.md create mode 100644 references/signed-urls-guide.md create mode 100644 references/top-errors.md create mode 100644 references/transformation-options.md create mode 100644 references/variants-guide.md create mode 100755 scripts/check-versions.sh create mode 100644 templates/batch-upload.ts create mode 100644 templates/direct-creator-upload-backend.ts create mode 100644 templates/direct-creator-upload-frontend.html create mode 100644 templates/package.json create mode 100644 templates/responsive-images-srcset.html create mode 100644 templates/signed-urls-generation.ts create mode 100644 templates/transform-via-url.ts create mode 100644 templates/transform-via-workers.ts create mode 100644 templates/upload-api-basic.ts create mode 100644 templates/upload-via-url.ts create mode 100644 templates/variants-management.ts create mode 100644 templates/wrangler-images-binding.jsonc diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..4496f6f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-images", + "description": "Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2253bd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# cloudflare-images + +Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4733cbc --- /dev/null +++ b/SKILL.md @@ -0,0 +1,563 @@ +--- +name: cloudflare-images +description: | + Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413. +license: MIT +--- + +# Cloudflare Images + +**Status**: Production Ready ✅ +**Last Updated**: 2025-11-23 +**Dependencies**: Cloudflare account with Images enabled +**Latest Versions**: Cloudflare Images API v2, @cloudflare/workers-types@4.20251121.0 + +**Recent Updates (2025)**: +- **August 2025**: AI Face Cropping GA (`gravity=face` with `zoom` control, GPU-based RetinaFace, 99.4% precision) +- **May 2025**: Media Transformations origin restrictions (default: same-domain only, configurable via dashboard) +- **Upcoming**: Background removal, generative upscale (planned features) + +--- + +## Overview + +Two features: **Images API** (upload/store with variants) and **Image Transformations** (resize any image via URL or Workers). + +--- + +## Quick Start + +**1. Enable**: Dashboard → Images → Get Account ID + API token (Cloudflare Images: Edit permission) + +**2. Upload**: +```bash +curl -X POST https://api.cloudflare.com/client/v4/accounts//images/v1 \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: multipart/form-data' \ + -F 'file=@./image.jpg' +``` + +**3. Serve**: `https://imagedelivery.net///public` + +**4. Transform** (optional): Dashboard → Images → Transformations → Enable for zone +```html + +``` + +--- + +## Upload Methods + +**1. File Upload**: POST to `/images/v1` with `file` (multipart/form-data), optional `id`, `requireSignedURLs`, `metadata` + +**2. Upload via URL**: POST with `url=https://example.com/image.jpg` (supports HTTP basic auth) + +**3. Direct Creator Upload** (one-time URLs, no API key exposure): + +Backend: POST to `/images/v2/direct_upload` → returns `uploadURL` +Frontend: POST file to `uploadURL` with FormData + +**CRITICAL CORS FIX**: +- ✅ Use `multipart/form-data` (let browser set header) +- ✅ Name field `file` (NOT `image`) +- ✅ Call `/direct_upload` from backend only +- ❌ Don't set `Content-Type: application/json` +- ❌ Don't call `/direct_upload` from browser + +--- + +## Image Transformations + +**URL**: `/cdn-cgi/image//` +- Sizing: `width=800,height=600,fit=cover` +- Quality: `quality=85` (1-100) +- Format: `format=auto` (WebP/AVIF auto-detection) +- **Cropping**: `gravity=auto` (smart crop), **`gravity=face`** (AI face detection, Aug 2025 GA), `gravity=center`, **`zoom=0.5`** (0-1 range, face crop tightness) +- Effects: `blur=10,sharpen=3,brightness=1.2` +- Fit: `scale-down`, `contain`, `cover`, `crop`, `pad` + +**Workers**: Use `cf.image` object in fetch +```typescript +fetch(imageURL, { + cf: { + image: { width: 800, quality: 85, format: 'auto', gravity: 'face', zoom: 0.8 } + } +}); +``` + +--- + +## Variants + +**Named Variants** (up to 100): Predefined transformations (e.g., `avatar`, `thumbnail`) +- Create: POST to `/images/v1/variants` with `id`, `options` +- Use: `imagedelivery.net///avatar` +- Works with signed URLs + +**Flexible Variants**: Dynamic params in URL (`w=400,sharpen=3`) +- Enable: PATCH `/images/v1/config` with `{"flexible_variants": true}` +- ❌ **Cannot use with signed URLs** (use named variants instead) + +--- + +## Signed URLs + +Generate HMAC-SHA256 tokens for private images (URL format: `?exp=&sig=`). + +**Algorithm**: `HMAC-SHA256(signingKey, imageId + variant + expiry)` → hex signature + +**See**: `templates/signed-urls-generation.ts` for Workers implementation + +--- + +## Critical Rules + +### Always Do + +✅ Use `multipart/form-data` for Direct Creator Upload +✅ Name the file field `file` (not `image` or other names) +✅ Call `/direct_upload` API from backend only (NOT browser) +✅ Use HTTPS URLs for transformations (HTTP not supported) +✅ URL-encode special characters in image paths +✅ Enable transformations on zone before using `/cdn-cgi/image/` +✅ Use named variants for private images (signed URLs) +✅ Check `Cf-Resized` header for transformation errors +✅ Set `format=auto` for automatic WebP/AVIF conversion +✅ Use `fit=scale-down` to prevent unwanted enlargement + +### Never Do + +❌ Use `application/json` Content-Type for file uploads +❌ Call `/direct_upload` from browser (CORS will fail) +❌ Use flexible variants with `requireSignedURLs=true` +❌ Resize SVG files (they're inherently scalable) +❌ Use HTTP URLs for transformations (HTTPS only) +❌ Put spaces or unescaped Unicode in URLs +❌ Transform the same image multiple times in Workers (causes 9403 loop) +❌ Exceed 100 megapixels image size +❌ Use `/cdn-cgi/image/` endpoint in Workers (use `cf.image` instead) +❌ Forget to enable transformations on zone before use + +--- + +## Known Issues Prevention + +This skill prevents **13+** documented issues. + +### Issue #1: Direct Creator Upload CORS Error + +**Error**: `Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed` + +**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114) + +**Why It Happens**: Server CORS settings only allow `multipart/form-data` for Content-Type header + +**Prevention**: +```javascript +// ✅ CORRECT +const formData = new FormData(); +formData.append('file', fileInput.files[0]); +await fetch(uploadURL, { + method: 'POST', + body: formData // Browser sets multipart/form-data automatically +}); + +// ❌ WRONG +await fetch(uploadURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, // CORS error + body: JSON.stringify({ file: base64Image }) +}); +``` + +### Issue #2: Error 5408 - Upload Timeout + +**Error**: `Error 5408` after ~15 seconds of upload + +**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336) + +**Why It Happens**: Cloudflare has 30-second request timeout; slow uploads or large files exceed limit + +**Prevention**: +- Compress images before upload (client-side with Canvas API) +- Use reasonable file size limits (e.g., max 10MB) +- Show upload progress to user +- Handle timeout errors gracefully + +```javascript +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +if (file.size > MAX_FILE_SIZE) { + alert('File too large. Please select an image under 10MB.'); + return; +} +``` + +### Issue #3: Error 400 - Invalid File Parameter + +**Error**: `400 Bad Request` with unhelpful error message + +**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629) + +**Why It Happens**: File field must be named `file` (not `image`, `photo`, etc.) + +**Prevention**: +```javascript +// ✅ CORRECT +formData.append('file', imageFile); + +// ❌ WRONG +formData.append('image', imageFile); // 400 error +formData.append('photo', imageFile); // 400 error +``` + +### Issue #4: CORS Preflight Failures + +**Error**: Preflight OPTIONS request blocked + +**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805) + +**Why It Happens**: Calling `/direct_upload` API directly from browser (should be backend-only) + +**Prevention**: +``` +ARCHITECTURE: +Browser → Backend API → POST /direct_upload → Returns uploadURL → Browser uploads to uploadURL +``` + +Never expose API token to browser. Generate upload URL on backend, return to frontend. + +### Issue #5: Error 9401 - Invalid Arguments + +**Error**: `Cf-Resized: err=9401` - Required cf.image options missing or invalid + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Missing required transformation parameters or invalid values + +**Prevention**: +```typescript +// ✅ CORRECT +fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } +}); + +// ❌ WRONG +fetch(imageURL, { + cf: { + image: { + width: 'large', // Must be number + quality: 150 // Max 100 + } + } +}); +``` + +### Issue #6: Error 9402 - Image Too Large + +**Error**: `Cf-Resized: err=9402` - Image too large or connection interrupted + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Image exceeds maximum area (100 megapixels) or download fails + +**Prevention**: +- Validate image dimensions before transforming +- Use reasonable source images (max 10000x10000px) +- Handle network errors gracefully + +### Issue #7: Error 9403 - Request Loop + +**Error**: `Cf-Resized: err=9403` - Worker fetching its own URL or already-resized image + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Transformation applied to already-transformed image, or Worker fetches itself + +**Prevention**: +```typescript +// ✅ CORRECT +if (url.pathname.startsWith('/images/')) { + const originalPath = url.pathname.replace('/images/', ''); + const originURL = `https://storage.example.com/${originalPath}`; + return fetch(originURL, { cf: { image: { width: 800 } } }); +} + +// ❌ WRONG +if (url.pathname.startsWith('/images/')) { + // Fetches worker's own URL, causes loop + return fetch(request, { cf: { image: { width: 800 } } }); +} +``` + +### Issue #8: Error 9406/9419 - Invalid URL Format + +**Error**: `Cf-Resized: err=9406` or `err=9419` - Non-HTTPS URL or URL has spaces/unescaped Unicode + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Image URL uses HTTP (not HTTPS) or contains invalid characters + +**Prevention**: +```typescript +// ✅ CORRECT +const imageURL = "https://example.com/images/photo%20name.jpg"; + +// ❌ WRONG +const imageURL = "http://example.com/images/photo.jpg"; // HTTP not allowed +const imageURL = "https://example.com/images/photo name.jpg"; // Space not encoded +``` + +Always use `encodeURIComponent()` for URL paths: +```typescript +const filename = "photo name.jpg"; +const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`; +``` + +### Issue #9: Error 9412 - Non-Image Response + +**Error**: `Cf-Resized: err=9412` - Origin returned HTML instead of image + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Origin server returns 404 page or error page (HTML) instead of image + +**Prevention**: +```typescript +// Verify URL before transforming +const originResponse = await fetch(imageURL, { method: 'HEAD' }); +const contentType = originResponse.headers.get('content-type'); + +if (!contentType?.startsWith('image/')) { + return new Response('Not an image', { status: 400 }); +} + +return fetch(imageURL, { cf: { image: { width: 800 } } }); +``` + +### Issue #10: Error 9413 - Max Image Area Exceeded + +**Error**: `Cf-Resized: err=9413` - Image exceeds 100 megapixels + +**Source**: [Cloudflare Images Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: Source image dimensions exceed 100 megapixels (e.g., 10000x10000px) + +**Prevention**: +- Validate image dimensions before upload +- Pre-process oversized images +- Reject images above threshold + +```typescript +const MAX_MEGAPIXELS = 100; + +if (width * height > MAX_MEGAPIXELS * 1_000_000) { + return new Response('Image too large', { status: 413 }); +} +``` + +### Issue #11: Flexible Variants + Signed URLs Incompatibility + +**Error**: Flexible variants don't work with private images + +**Source**: [Cloudflare Images Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/) + +**Why It Happens**: Flexible variants cannot be used with `requireSignedURLs=true` + +**Prevention**: +```typescript +// ✅ CORRECT - Use named variants for private images +await uploadImage({ + file: imageFile, + requireSignedURLs: true // Use named variants: /public, /avatar, etc. +}); + +// ❌ WRONG - Flexible variants don't support signed URLs +// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true +``` + +### Issue #12: SVG Resizing Limitation + +**Error**: SVG files don't resize via transformations + +**Source**: [Cloudflare Images Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files) + +**Why It Happens**: SVG is inherently scalable (vector format), resizing not applicable + +**Prevention**: +```typescript +// SVGs can be served but not resized +// Use any variant name as placeholder +// https://imagedelivery.net///public + +// SVG will be served at original size regardless of variant settings +``` + +### Issue #13: EXIF Metadata Stripped by Default + +**Error**: GPS data, camera settings removed from uploaded JPEGs + +**Source**: [Cloudflare Images Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata) + +**Why It Happens**: Default behavior strips all metadata except copyright + +**Prevention**: +```typescript +// Preserve metadata +fetch(imageURL, { + cf: { + image: { + width: 800, + metadata: 'keep' // Options: 'none', 'copyright', 'keep' + } + } +}); +``` + +**Options**: +- `none`: Strip all metadata +- `copyright`: Keep only copyright tag (default for JPEG) +- `keep`: Preserve most EXIF metadata including GPS + +--- + +## Using Bundled Resources + +### Templates (templates/) + +Copy-paste ready code for common patterns: + +1. **wrangler-images-binding.jsonc** - Wrangler configuration (no binding needed) +2. **upload-api-basic.ts** - Upload file to Images API +3. **upload-via-url.ts** - Ingest image from external URL +4. **direct-creator-upload-backend.ts** - Generate one-time upload URLs +5. **direct-creator-upload-frontend.html** - User upload form +6. **transform-via-url.ts** - URL transformation examples +7. **transform-via-workers.ts** - Workers transformation patterns +8. **variants-management.ts** - Create/list/delete variants +9. **signed-urls-generation.ts** - HMAC-SHA256 signed URL generation +10. **responsive-images-srcset.html** - Responsive image patterns +11. **batch-upload.ts** - Batch API for high-volume uploads + +**Usage**: +```bash +cp templates/upload-api-basic.ts src/upload.ts +# Edit with your account ID and API token +``` + +### References (references/) + +In-depth documentation Claude can load as needed: + +1. **api-reference.md** - Complete API endpoints (upload, list, delete, variants) +2. **transformation-options.md** - All transform params with examples +3. **variants-guide.md** - Named vs flexible variants, when to use each +4. **signed-urls-guide.md** - HMAC-SHA256 implementation details +5. **direct-upload-complete-workflow.md** - Full architecture and flow +6. **responsive-images-patterns.md** - srcset, sizes, art direction +7. **format-optimization.md** - WebP/AVIF auto-conversion strategies +8. **top-errors.md** - All 13+ errors with detailed troubleshooting + +**When to load**: +- Deep-dive into specific feature +- Troubleshooting complex issues +- Understanding API details +- Implementing advanced patterns + +### Scripts (scripts/) + +**check-versions.sh** - Verify API endpoints are current + +--- + +## Advanced Topics + +**Custom Domains**: Serve from your domain via `/cdn-cgi/imagedelivery///` (requires domain on Cloudflare, proxied). Use Transform Rules for custom paths. + +**Batch API**: High-volume uploads via `batch.imagedelivery.net` with batch tokens (Dashboard → Images → Batch API) + +**Webhooks**: Notifications for Direct Creator Upload (Dashboard → Notifications → Webhooks). Payload includes `imageId`, `status`, `metadata`. + +--- + +## Troubleshooting + +### Problem: Images not transforming + +**Symptoms**: `/cdn-cgi/image/...` returns original image or 404 + +**Solutions**: +1. Enable transformations on zone: Dashboard → Images → Transformations → Enable for zone +2. Verify zone is proxied through Cloudflare (orange cloud) +3. Check source image is publicly accessible +4. Wait 5-10 minutes for settings to propagate + +### Problem: Direct upload returns CORS error + +**Symptoms**: `Access-Control-Allow-Origin` error in browser console + +**Solutions**: +1. Use `multipart/form-data` encoding (let browser set Content-Type) +2. Don't call `/direct_upload` from browser; call from backend +3. Name file field `file` (not `image`) +4. Remove manual Content-Type header + +### Problem: Worker transformations return 9403 loop error + +**Symptoms**: `Cf-Resized: err=9403` in response headers + +**Solutions**: +1. Don't fetch Worker's own URL (use external origin) +2. Don't transform already-resized images +3. Check URL routing logic to avoid loops + +### Problem: Signed URLs not working + +**Symptoms**: 403 Forbidden when accessing signed URL + +**Solutions**: +1. Verify image uploaded with `requireSignedURLs=true` +2. Check signature generation (HMAC-SHA256) +3. Ensure expiry timestamp is in future +4. Verify signing key matches dashboard (Images → Keys) +5. Cannot use flexible variants with signed URLs (use named variants) + +### Problem: Images uploaded but not appearing + +**Symptoms**: Upload returns 200 OK but image not in dashboard + +**Solutions**: +1. Check for `draft: true` in response (Direct Creator Upload) +2. Wait for upload to complete (check via GET `/images/v1/{id}`) +3. Verify account ID matches +4. Check for upload errors in webhooks + +--- + +## Official Documentation + +- **Cloudflare Images**: https://developers.cloudflare.com/images/ +- **Get Started**: https://developers.cloudflare.com/images/get-started/ +- **Upload Images**: https://developers.cloudflare.com/images/upload-images/ +- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/ +- **Transform Images**: https://developers.cloudflare.com/images/transform-images/ +- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/ +- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/ +- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/ +- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/ +- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/ +- **API Reference**: https://developers.cloudflare.com/api/resources/images/ + +--- + +## Package Versions + +**Last Verified**: 2025-11-23 +**API Version**: v2 (direct uploads), v1 (standard uploads) +**Optional**: @cloudflare/workers-types@4.20251121.0 diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..16568a2 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,129 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jezweb/claude-skills:skills/cloudflare-images", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "7bc4849a97dc79c9120657ff4d9914357c7569aa", + "treeHash": "18d254c96b6d7b4a32678a5bbb85814a814aaffbc5449d7a09b0b9f0ba5688ed", + "generatedAt": "2025-11-28T10:18:58.353667Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "cloudflare-images", + "description": "Store and transform images with Cloudflare Images API and transformations. Use when: uploading images, implementing direct creator uploads, creating variants, generating signed URLs, optimizing formats (WebP/AVIF), transforming via Workers, or debugging CORS, multipart, or error codes 9401-9413.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "5fc400d45e5f4bc167bb2a599bf71a945cbbfa0b850512cac372a872e354ba47" + }, + { + "path": "SKILL.md", + "sha256": "f4c6d64b2b8d11e6692fc8023e176791e9db44faa59ec60642bae4b0996477ed" + }, + { + "path": "references/transformation-options.md", + "sha256": "a19f13cd568ee5d736e3e40c266ed1ddcbe7bd555f7e7a6ee92c5273e2ff5945" + }, + { + "path": "references/direct-upload-complete-workflow.md", + "sha256": "9a829def528fc4c4032536a44dc06b61cbc82bce22185a01aee2f6c6f5a9c9d1" + }, + { + "path": "references/top-errors.md", + "sha256": "ce6ece72c6795a7d2f6ea3c422437c28c3a7b36fbf5bdb5c46db37349b854c20" + }, + { + "path": "references/variants-guide.md", + "sha256": "2ab0cfcac5d4f54d78cd39fc988ced51b049ce8f0300b0f0ef1054ddbae3e16f" + }, + { + "path": "references/signed-urls-guide.md", + "sha256": "5f16310d425c4c4812e39fc871aa2696e172419ddbff890a89a12371f0940dc4" + }, + { + "path": "references/format-optimization.md", + "sha256": "dd69136c7fdfd2a083292331280c8702ecd0d41f46e9f818cf4d81ff2c8d8758" + }, + { + "path": "references/responsive-images-patterns.md", + "sha256": "b5b5981640f079a760d14a5bd118028817270f33596a42e08fd59f5f6919200d" + }, + { + "path": "references/api-reference.md", + "sha256": "dbbb034ded58a2c255760f5f0a62da8261ee15f8b1a4cc88712a41008c3a14ed" + }, + { + "path": "scripts/check-versions.sh", + "sha256": "7fe965555e2602fe57412743250fda1411118a5d3c0b2a125cd9d9619a45f9fe" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "c75f5055156f8fd1bdf3f2fe985aba36363f94a59f98731785399275020f905d" + }, + { + "path": "templates/transform-via-workers.ts", + "sha256": "44be03a6442750818121b68eb963db9f94903feea318876ee712acc633e28b01" + }, + { + "path": "templates/transform-via-url.ts", + "sha256": "15e7122245398f4465104043ff5dee443b082e21756a19ab1a1f43e885106ad1" + }, + { + "path": "templates/wrangler-images-binding.jsonc", + "sha256": "0575beed7089728428394d0894b5ebc1f6cdbe897f23594b244dfa5ed369fdd3" + }, + { + "path": "templates/responsive-images-srcset.html", + "sha256": "2e99dc2bd4e84af4cd38340f50ced9e4772520bb3c6024dd083fe567512b2638" + }, + { + "path": "templates/upload-api-basic.ts", + "sha256": "55937da60b1e6210f19b4f341c3ef0fad475f7dd941e63bc66b7dcf9525859df" + }, + { + "path": "templates/direct-creator-upload-frontend.html", + "sha256": "1c7f2731d281c4666ba522b5217febf47a92e15560196a22bd0fc1799b7681ca" + }, + { + "path": "templates/signed-urls-generation.ts", + "sha256": "153fd80ec86728cf78a073e81e70ba1616c58f0f55cfd917123e02ddc070de26" + }, + { + "path": "templates/package.json", + "sha256": "b9d3d2822116d53f2171c638d4eeedc1f1f455cda37a852fc476dd49aef08604" + }, + { + "path": "templates/direct-creator-upload-backend.ts", + "sha256": "6a46ace900b3a6f1a19206c120aa9d1a99dd5e688c7def72dc715a54cb4d254e" + }, + { + "path": "templates/variants-management.ts", + "sha256": "18329a67a61e64d5e0912ec3a0f0fecb0bf74c066cd9b1b864717f670380c80b" + }, + { + "path": "templates/upload-via-url.ts", + "sha256": "bbb7498b2e4eb33a731a81fdc1ba457022688ccc994654daca0dbb4fe038dc7e" + }, + { + "path": "templates/batch-upload.ts", + "sha256": "1db4a9cef4ab2bdaee8b488d8713294c12ffa9845d67edd2535a924b85d6e100" + } + ], + "dirSha256": "18d254c96b6d7b4a32678a5bbb85814a814aaffbc5449d7a09b0b9f0ba5688ed" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/api-reference.md b/references/api-reference.md new file mode 100644 index 0000000..21e6cfb --- /dev/null +++ b/references/api-reference.md @@ -0,0 +1,403 @@ +# Cloudflare Images API Reference + +Complete API endpoints for Cloudflare Images. + +**Base URL**: `https://api.cloudflare.com/client/v4/accounts/{account_id}` +**Batch API**: `https://batch.imagedelivery.net` + +--- + +## Authentication + +All requests require an API token with **Cloudflare Images: Edit** permission. + +```bash +Authorization: Bearer +``` + +Get API token: Dashboard → My Profile → API Tokens → Create Token + +--- + +## Upload Endpoints + +### Upload Image (File) + +`POST /accounts/{account_id}/images/v1` + +Upload an image file. + +**Headers**: +- `Authorization: Bearer ` +- `Content-Type: multipart/form-data` + +**Form Fields**: +- `file` (required): Image file +- `id` (optional): Custom ID (auto-generated if not provided) +- `requireSignedURLs` (optional): `true` for private images +- `metadata` (optional): JSON object (max 1024 bytes) + +**Example**: +```bash +curl --request POST \ + https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \ + --header "Authorization: Bearer " \ + --form 'file=@./image.jpg' \ + --form 'requireSignedURLs=false' \ + --form 'metadata={"key":"value"}' +``` + +**Response**: +```json +{ + "success": true, + "result": { + "id": "2cdc28f0-017a-49c4-9ed7-87056c83901", + "filename": "image.jpg", + "uploaded": "2022-01-31T16:39:28.458Z", + "requireSignedURLs": false, + "variants": [ + "https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0.../public" + ] + } +} +``` + +--- + +### Upload via URL + +`POST /accounts/{account_id}/images/v1` + +Ingest image from external URL. + +**Form Fields**: +- `url` (required): Image URL to ingest +- `id` (optional): Custom ID +- `requireSignedURLs` (optional): `true` for private images +- `metadata` (optional): JSON object + +**Example**: +```bash +curl --request POST \ + https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \ + --header "Authorization: Bearer " \ + --form 'url=https://example.com/image.jpg' \ + --form 'metadata={"source":"external"}' +``` + +**Note**: Cannot use both `file` and `url` in same request. + +--- + +### Direct Creator Upload + +`POST /accounts/{account_id}/images/v2/direct_upload` + +Generate one-time upload URL for user uploads. + +**Headers**: +- `Authorization: Bearer ` +- `Content-Type: application/json` + +**Body**: +```json +{ + "requireSignedURLs": false, + "metadata": {"userId": "12345"}, + "expiry": "2025-10-26T18:00:00Z", + "id": "custom-id" +} +``` + +**Fields**: +- `requireSignedURLs` (optional): `true` for private images +- `metadata` (optional): JSON object +- `expiry` (optional): ISO 8601 timestamp (default: 30min, max: 6hr) +- `id` (optional): Custom ID (cannot use with `requireSignedURLs=true`) + +**Response**: +```json +{ + "success": true, + "result": { + "id": "2cdc28f0-017a-49c4-9ed7-87056c83901", + "uploadURL": "https://upload.imagedelivery.net/..." + } +} +``` + +**Frontend Upload**: +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); // MUST be named 'file' + +await fetch(uploadURL, { + method: 'POST', + body: formData // NO Content-Type header +}); +``` + +--- + +## Image Management + +### List Images + +`GET /accounts/{account_id}/images/v2` + +List all images (paginated). + +**Query Params**: +- `page` (optional): Page number (default: 1) +- `per_page` (optional): Results per page (default: 100, max: 100) + +**Example**: +```bash +curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2?page=1&per_page=50" \ + --header "Authorization: Bearer " +``` + +**Response**: +```json +{ + "success": true, + "result": { + "images": [ + { + "id": "2cdc28f0-017a-49c4-9ed7-87056c83901", + "filename": "image.jpg", + "uploaded": "2022-01-31T16:39:28.458Z", + "requireSignedURLs": false, + "variants": ["https://imagedelivery.net/.../public"] + } + ] + } +} +``` + +--- + +### Get Image Details + +`GET /accounts/{account_id}/images/v1/{image_id}` + +Get details of specific image. + +**Example**: +```bash +curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \ + --header "Authorization: Bearer " +``` + +**Response**: +```json +{ + "success": true, + "result": { + "id": "2cdc28f0-017a-49c4-9ed7-87056c83901", + "filename": "image.jpg", + "uploaded": "2022-01-31T16:39:28.458Z", + "requireSignedURLs": false, + "draft": false, + "variants": ["https://imagedelivery.net/.../public"] + } +} +``` + +**Note**: `draft: true` means Direct Creator Upload not completed yet. + +--- + +### Delete Image + +`DELETE /accounts/{account_id}/images/v1/{image_id}` + +Delete an image. + +**Example**: +```bash +curl --request DELETE \ + "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}" \ + --header "Authorization: Bearer " +``` + +**Response**: +```json +{ + "success": true +} +``` + +--- + +## Variants Management + +### Create Variant + +`POST /accounts/{account_id}/images/v1/variants` + +Create a new variant. + +**Body**: +```json +{ + "id": "thumbnail", + "options": { + "fit": "cover", + "width": 300, + "height": 300, + "metadata": "none" + }, + "neverRequireSignedURLs": false +} +``` + +**Options**: +- `fit`: `scale-down`, `contain`, `cover`, `crop`, `pad` +- `width`: Max width in pixels +- `height`: Max height in pixels +- `metadata`: `none`, `copyright`, `keep` + +**Example**: +```bash +curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \ + --header "Authorization: Bearer " \ + --header "Content-Type: application/json" \ + --data '{"id":"thumbnail","options":{"fit":"cover","width":300,"height":300}}' +``` + +--- + +### List Variants + +`GET /accounts/{account_id}/images/v1/variants` + +List all variants. + +**Example**: +```bash +curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \ + --header "Authorization: Bearer " +``` + +--- + +### Get Variant + +`GET /accounts/{account_id}/images/v1/variants/{variant_id}` + +Get specific variant details. + +--- + +### Update Variant + +`PATCH /accounts/{account_id}/images/v1/variants/{variant_id}` + +Update existing variant. + +**Body**: +```json +{ + "options": { + "width": 350, + "height": 350 + } +} +``` + +--- + +### Delete Variant + +`DELETE /accounts/{account_id}/images/v1/variants/{variant_id}` + +Delete a variant. + +--- + +### Enable Flexible Variants + +`PATCH /accounts/{account_id}/images/v1/config` + +Enable or disable flexible variants (dynamic transformations). + +**Body**: +```json +{ + "flexible_variants": true +} +``` + +--- + +## Batch API + +Same endpoints as regular API, but different host and authentication. + +**Host**: `https://batch.imagedelivery.net` +**Auth**: Batch token (create in Dashboard → Images → Batch API) + +**Endpoints**: +- `POST /images/v1` - Upload image +- `GET /images/v2` - List images +- `DELETE /images/v1/{image_id}` - Delete image + +**Example**: +```bash +curl "https://batch.imagedelivery.net/images/v1" \ + --header "Authorization: Bearer " \ + --form 'file=@./image.jpg' +``` + +--- + +## Error Codes + +### HTTP Status Codes +- `200 OK` - Request successful +- `400 Bad Request` - Invalid request (check error message) +- `401 Unauthorized` - Invalid or missing API token +- `403 Forbidden` - Insufficient permissions +- `404 Not Found` - Resource not found +- `413 Payload Too Large` - File too large +- `429 Too Many Requests` - Rate limit exceeded +- `500 Internal Server Error` - Cloudflare error +- `502 Bad Gateway` - Transformation error + +### Cloudflare Errors +Check `errors` array in response: +```json +{ + "success": false, + "errors": [ + { + "code": 5400, + "message": "Error description" + } + ] +} +``` + +Common error codes: +- `5400` - Invalid request +- `5408` - Upload timeout +- `5454` - Unsupported protocol + +--- + +## Rate Limits + +- **Standard uploads**: No published rate limits +- **Direct Creator Upload**: Limited by one-time URL expiry (default 30min, max 6hr) +- **Batch API**: Contact Cloudflare for high-volume needs + +--- + +## Official Documentation + +- **Images API**: https://developers.cloudflare.com/api/resources/images/ +- **Upload Images**: https://developers.cloudflare.com/images/upload-images/ +- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/ +- **Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/ diff --git a/references/direct-upload-complete-workflow.md b/references/direct-upload-complete-workflow.md new file mode 100644 index 0000000..8c117c9 --- /dev/null +++ b/references/direct-upload-complete-workflow.md @@ -0,0 +1,393 @@ +# Direct Creator Upload - Complete Workflow + +Complete architecture and implementation guide for user-uploaded images. + +--- + +## Architecture Overview + +``` +┌─────────┐ ┌─────────┐ ┌──────────┐ +│ Browser │ │ Backend │ │Cloudflare│ +│ (User) │ │ API │ │ Images │ +└────┬────┘ └────┬────┘ └─────┬────┘ + │ │ │ + │ 1. Request upload URL │ │ + ├─────────────────────────────>│ │ + │ POST /api/upload-url │ │ + │ { userId: "123" } │ │ + │ │ │ + │ │ 2. Generate upload URL │ + │ ├──────────────────────────────>│ + │ │ POST /direct_upload │ + │ │ { requireSignedURLs, metadata }│ + │ │ │ + │ │ 3. Return uploadURL + ID │ + │ │<──────────────────────────────┤ + │ │ { uploadURL, id } │ + │ │ │ + │ 4. Return uploadURL │ │ + │<─────────────────────────────┤ │ + │ { uploadURL, imageId } │ │ + │ │ │ + │ 5. Upload file directly │ │ + ├──────────────────────────────────────────────────────────────>│ + │ POST uploadURL │ │ + │ FormData: { file } │ │ + │ │ │ + │ 6. Success response │ │ + │<──────────────────────────────────────────────────────────────┤ + │ { success: true } │ │ + │ │ │ + │ 7. (Optional) Webhook │ │ + │ │<──────────────────────────────┤ + │ │ POST /webhook │ + │ │ { imageId, status } │ + │ │ │ +``` + +--- + +## Implementation Steps + +### Step 1: Backend - Generate Upload URL + +**Endpoint**: `POST /api/upload-url` + +```typescript +// backend.ts +interface Env { + IMAGES_ACCOUNT_ID: string; + IMAGES_API_TOKEN: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Parse request + const body = await request.json<{ + userId?: string; + requireSignedURLs?: boolean; + }>(); + + // Generate one-time upload URL + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + requireSignedURLs: body.requireSignedURLs ?? false, + metadata: { + userId: body.userId || 'anonymous', + uploadedAt: new Date().toISOString() + }, + expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour + }) + } + ); + + const result = await response.json(); + + return Response.json({ + uploadURL: result.result?.uploadURL, + imageId: result.result?.id + }); + } +}; +``` + +### Step 2: Frontend - Request Upload URL + +```javascript +// frontend.js +async function requestUploadURL() { + const response = await fetch('/api/upload-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: getCurrentUserId(), + requireSignedURLs: false + }) + }); + + const { uploadURL, imageId } = await response.json(); + return { uploadURL, imageId }; +} +``` + +### Step 3: Frontend - Upload to Cloudflare + +```javascript +async function uploadImage(file) { + // Step 1: Get upload URL + const { uploadURL, imageId } = await requestUploadURL(); + + // Step 2: Upload directly to Cloudflare + const formData = new FormData(); + formData.append('file', file); // MUST be named 'file' + + const response = await fetch(uploadURL, { + method: 'POST', + body: formData // NO Content-Type header - browser sets multipart/form-data + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + return imageId; +} +``` + +--- + +## Frontend HTML Example + +```html +
+ + +
+
+ + +``` + +--- + +## Webhook Integration + +### Configure Webhook + +1. Dashboard → Notifications → Destinations → Webhooks → Create +2. Enter webhook URL: `https://your-backend.com/webhook` +3. Notifications → All Notifications → Add → Images → Select webhook + +### Handle Webhook + +```typescript +// backend-webhook.ts +export default { + async fetch(request: Request): Promise { + const webhook = await request.json(); + + console.log('Image upload webhook:', webhook); + // { + // imageId: "abc123", + // status: "uploaded", + // metadata: { userId: "user-123" } + // } + + // Update database + await db.images.create({ + id: webhook.imageId, + userId: webhook.metadata.userId, + status: webhook.status, + uploadedAt: new Date() + }); + + return Response.json({ received: true }); + } +}; +``` + +--- + +## Draft vs Uploaded State + +When you generate upload URL, image record is created in **draft** state. + +**Check status**: +```typescript +const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1/${imageId}`, + { + headers: { 'Authorization': `Bearer ${apiToken}` } + } +); + +const result = await response.json(); + +if (result.result?.draft) { + console.log('Upload not completed yet'); +} else { + console.log('Upload complete, image available'); +} +``` + +--- + +## Error Handling + +### Backend Errors + +```typescript +try { + const response = await fetch(directUploadURL, { ... }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`Cloudflare error: ${error.errors?.[0]?.message}`); + } + + return result; + +} catch (error) { + console.error('Failed to generate upload URL:', error); + return Response.json( + { error: 'Internal server error' }, + { status: 500 } + ); +} +``` + +### Frontend Errors + +```javascript +// File size validation +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +if (file.size > MAX_SIZE) { + throw new Error('File too large (max 10MB)'); +} + +// File type validation +if (!file.type.startsWith('image/')) { + throw new Error('Please select an image file'); +} + +// Upload timeout +const timeout = setTimeout(() => { + throw new Error('Upload timeout (30s limit)'); +}, 28000); // 28s (before Cloudflare's 30s timeout) + +try { + await fetch(uploadURL, { body: formData }); + clearTimeout(timeout); +} catch (error) { + clearTimeout(timeout); + throw error; +} +``` + +--- + +## Custom ID Support + +```typescript +// Generate upload URL with custom ID +const response = await fetch(directUploadURL, { + method: 'POST', + headers: { ... }, + body: JSON.stringify({ + id: `user-${userId}-profile`, // Custom ID + metadata: { userId } + }) +}); + +// Access with custom ID +const imageURL = `https://imagedelivery.net/${accountHash}/user-${userId}-profile/public`; +``` + +**Note**: Custom IDs cannot be used with `requireSignedURLs=true`. + +--- + +## Expiry Configuration + +```typescript +// Default: 30 minutes +// Min: 2 minutes +// Max: 6 hours + +const expiry = new Date(Date.now() + 6 * 60 * 60 * 1000); // 6 hours + +const response = await fetch(directUploadURL, { + method: 'POST', + body: JSON.stringify({ + expiry: expiry.toISOString() + }) +}); +``` + +--- + +## Security Best Practices + +1. **Never expose API token to browser**: Backend-only +2. **Validate file type and size**: Frontend and backend +3. **Rate limit upload URL generation**: Prevent abuse +4. **Associate uploads with users**: Track in metadata +5. **Implement webhooks**: Verify successful uploads +6. **Set reasonable expiry**: 30min-1hr for most cases +7. **Use signed URLs for private content**: `requireSignedURLs=true` + +--- + +## Testing + +```bash +# Test backend endpoint +curl -X POST http://localhost:8787/api/upload-url \ + -H "Content-Type: application/json" \ + -d '{"userId":"test-user"}' + +# Test upload (replace UPLOAD_URL with response) +curl -X POST "UPLOAD_URL" \ + -F "file=@./test-image.jpg" +``` + +--- + +## Official Documentation + +- **Direct Creator Upload**: https://developers.cloudflare.com/images/upload-images/direct-creator-upload/ +- **Configure Webhooks**: https://developers.cloudflare.com/images/manage-images/configure-webhooks/ diff --git a/references/format-optimization.md b/references/format-optimization.md new file mode 100644 index 0000000..a3e5fe1 --- /dev/null +++ b/references/format-optimization.md @@ -0,0 +1,359 @@ +# Format Optimization + +Complete guide to automatic WebP/AVIF conversion and format selection. + +--- + +## format=auto (Recommended) + +Automatically serve optimal format based on browser support. + +**Priority**: +1. **AVIF** - Best compression (Chrome, Edge) +2. **WebP** - Good compression (Safari, Firefox) +3. **Original format** - Fallback (older browsers) + +**Usage**: +```typescript +// URL format +/cdn-cgi/image/width=800,quality=85,format=auto/image.jpg + +// Workers format +fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } +}); + +// Cloudflare Images +https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto +``` + +--- + +## Browser Support Detection + +Cloudflare automatically checks the `Accept` header. + +**Chrome/Edge**: +``` +Accept: image/avif,image/webp,image/apng,image/*,*/* +``` +→ Serves AVIF + +**Safari**: +``` +Accept: image/webp,image/apng,image/*,*/* +``` +→ Serves WebP + +**Older browsers**: +``` +Accept: image/jpeg,image/png,image/*,*/* +``` +→ Serves original format (JPEG) + +--- + +## Manual Format Selection + +### In URL Transformations + +```html + + + + + + + + + + + +``` + +### In Workers + +```typescript +// Get optimal format from Accept header +function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' { + const accept = request.headers.get('accept') || ''; + + if (/image\/avif/.test(accept)) { + return 'avif'; + } else if (/image\/webp/.test(accept)) { + return 'webp'; + } + + return 'auto'; // Cloudflare decides +} + +return fetch(imageURL, { + cf: { + image: { + format: getOptimalFormat(request) + } + } +}); +``` + +--- + +## Format Comparison + +| Format | Compression | Quality | Support | Use Case | +|--------|-------------|---------|---------|----------| +| **AVIF** | Best (~50% smaller) | Excellent | Modern browsers | First choice (auto) | +| **WebP** | Good (~30% smaller) | Excellent | Wide support | Fallback from AVIF | +| **JPEG** | Standard | Good | Universal | Fallback, photos | +| **PNG** | Lossless | Lossless | Universal | Graphics, transparency | + +**File Size Example** (1920x1080 photo): +- Original JPEG: 500 KB +- WebP: ~350 KB (30% smaller) +- AVIF: ~250 KB (50% smaller) + +--- + +## Progressive vs Baseline JPEG + +**Progressive JPEG** (default): +- Loads in multiple passes (low→high quality) +- Better for slow connections +- Slightly larger file size + +**Baseline JPEG**: +- Loads top-to-bottom +- Better for older devices +- Slightly smaller file size + +**Usage**: +``` +format=jpeg → Progressive JPEG +format=baseline-jpeg → Baseline JPEG +``` + +--- + +## WebP Compression Modes + +```typescript +// Fast compression (faster encoding, larger file) +fetch(imageURL, { + cf: { + image: { + format: 'webp', + compression: 'fast' + } + } +}); + +// Lossless WebP (no quality loss, larger file) +fetch(imageURL, { + cf: { + image: { + format: 'webp', + compression: 'lossless' + } + } +}); +``` + +--- + +## Responsive Images with format=auto + +```html + + + + + + + + + Responsive image with format fallbacks + + + +Auto-format responsive image +``` + +--- + +## Quality Recommendations by Format + +```typescript +const qualitySettings = { + jpeg: 85, // Standard for photos + webp: 85, // Same as JPEG + avif: 85, // AVIF efficient at same quality + png: undefined, // Lossless (quality N/A) + graphics: 95 // High quality for logos/text +}; + +// Photos +/cdn-cgi/image/width=800,quality=85,format=auto/photo.jpg + +// Graphics with text +/cdn-cgi/image/width=800,quality=95,format=auto/logo.png + +// Thumbnails (lower quality acceptable) +/cdn-cgi/image/width=300,quality=75,format=auto/thumb.jpg +``` + +--- + +## Animation Support + +**GIF**: +``` +format=auto → Still GIF or first frame +anim=true → Preserve animation +``` + +**Animated WebP**: +```typescript +fetch(animatedGif, { + cf: { + image: { + format: 'webp', + anim: true // Preserve animation + } + } +}); +``` + +--- + +## Metadata Handling + +**Strip metadata** (smaller file size): +``` +metadata=none +``` + +**Keep copyright** (default for JPEG): +``` +metadata=copyright +``` + +**Keep all EXIF** (GPS, camera settings): +``` +metadata=keep +``` + +**Example**: +``` +/cdn-cgi/image/width=800,format=auto,metadata=none/photo.jpg +``` + +--- + +## Cost Optimization + +1. **Use format=auto**: Smallest files = less bandwidth +2. **Reasonable quality**: 80-85 for photos, 90-95 for graphics +3. **Strip metadata**: `metadata=none` for public images +4. **Cache at edge**: First transformation billable, subsequent free +5. **WebP animations**: Convert GIF to animated WebP (smaller) + +--- + +## Testing Format Support + +```html + +``` + +**But**: Let Cloudflare handle this with `format=auto`! + +--- + +## Common Patterns + +### Hero Image +``` +width=1920,height=1080,fit=cover,quality=85,format=auto,metadata=none +``` + +### Thumbnail +``` +width=300,height=300,fit=cover,quality=75,format=auto,metadata=none +``` + +### Avatar +``` +width=200,height=200,fit=cover,gravity=face,quality=90,format=auto +``` + +### Product Photo +``` +width=800,height=800,fit=contain,quality=90,sharpen=2,format=auto +``` + +### Blur Placeholder (LQIP) +``` +width=50,quality=10,blur=20,format=webp,metadata=none +``` + +--- + +## Best Practices + +1. **Always use format=auto**: Let Cloudflare optimize +2. **Quality 80-90**: Balance file size and quality +3. **Strip unnecessary metadata**: Smaller files +4. **Test on real devices**: Verify format delivery +5. **Monitor bandwidth**: Check Cloudflare Analytics +6. **Use WebP for animations**: Smaller than GIF +7. **Progressive JPEG for photos**: Better perceived load time + +--- + +## Official Documentation + +- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/ +- **Supported Formats**: https://developers.cloudflare.com/images/transform-images/#supported-formats-and-limitations diff --git a/references/responsive-images-patterns.md b/references/responsive-images-patterns.md new file mode 100644 index 0000000..aaea764 --- /dev/null +++ b/references/responsive-images-patterns.md @@ -0,0 +1,274 @@ +# Responsive Images Patterns + +Complete guide to serving optimal images for different devices and screen sizes. + +--- + +## srcset with Named Variants + +Best for consistent, predefined sizes. + +```html +Responsive image +``` + +**Variants to create**: +- `mobile`: width=480, fit=scale-down +- `tablet`: width=768, fit=scale-down +- `desktop`: width=1920, fit=scale-down + +--- + +## srcset with Flexible Variants + +Best for dynamic sizing (public images only). + +```html +Responsive image +``` + +--- + +## Art Direction (Different Crops) + +Serve different image crops for mobile vs desktop. + +```html + + + + + + + + + Art directed image + +``` + +**Variants to create**: +- `mobile-square`: width=480, height=480, fit=cover +- `desktop-wide`: width=1920, height=1080, fit=cover + +--- + +## High-DPI (Retina) Displays + +Serve 2x images for high-resolution screens. + +```html +Retina-ready image +``` + +--- + +## Blur Placeholder (LQIP) + +Load tiny blurred placeholder first, then swap to full image. + +```html +Image with LQIP + + +``` + +--- + +## Lazy Loading + +Defer loading below-the-fold images. + +```html + +... + + +Lazy loaded image + + +``` + +--- + +## URL Transformations (/cdn-cgi/image/) + +Transform ANY publicly accessible image (not just Cloudflare Images storage). + +```html +Transformed origin image +``` + +--- + +## Recommended Breakpoints + +```javascript +const breakpoints = { + mobile: 480, // Small phones + tablet: 768, // Tablets + desktop: 1024, // Laptops + wide: 1920, // Desktops + ultrawide: 2560 // Large displays +}; +``` + +**sizes attribute**: +```html +sizes=" + (max-width: 480px) 480px, + (max-width: 768px) 768px, + (max-width: 1024px) 1024px, + (max-width: 1920px) 1920px, + 2560px +" +``` + +--- + +## Complete Example + +```html + + + + + + + + + + + + Hero image + + + + Gallery image + + +``` + +--- + +## Best Practices + +1. **Always use format=auto**: Optimal WebP/AVIF delivery +2. **Add loading="lazy"**: Below-the-fold images +3. **Match sizes to CSS layout**: Use `sizes` attribute correctly +4. **Provide descriptive alt text**: Accessibility +5. **Use LQIP for perceived performance**: Better UX +6. **Named variants for private**: Signed URLs compatible +7. **Flexible variants for public**: Dynamic sizing +8. **Limit srcset to 3-5 sizes**: Balance performance vs flexibility + +--- + +## Official Documentation + +- **Responsive Images (MDN)**: https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images +- **Cloudflare Images**: https://developers.cloudflare.com/images/ diff --git a/references/signed-urls-guide.md b/references/signed-urls-guide.md new file mode 100644 index 0000000..1f191b8 --- /dev/null +++ b/references/signed-urls-guide.md @@ -0,0 +1,350 @@ +# Signed URLs Guide + +Complete guide to generating signed URLs for private images using HMAC-SHA256. + +--- + +## What Are Signed URLs? + +Time-limited URLs for serving private images securely. + +**Format**: +``` +https://imagedelivery.net///?exp=&sig= +``` + +**Use cases**: +- User profile photos (private until shared) +- Paid content (time-limited access) +- Temporary downloads +- Secure image delivery + +--- + +## Requirements + +1. **Upload with signed URLs enabled**: +```javascript +await uploadImage(file, { + requireSignedURLs: true // Image requires signed URL +}); +``` + +2. **Get signing key**: +Dashboard → Images → Keys → Generate key + +3. **Use named variants only**: +Flexible variants NOT compatible with signed URLs. + +--- + +## Signature Algorithm (HMAC-SHA256) + +### String to Sign + +``` +{imageId}{variant}{expiry} +``` + +**Example**: +``` +Image ID: abc123 +Variant: public +Expiry: 1735228800 + +String to sign: abc123public1735228800 +``` + +### Generate Signature + +**Workers** (recommended): +```typescript +async function generateSignature( + imageId: string, + variant: string, + expiry: number, + signingKey: string +): Promise { + const stringToSign = `${imageId}${variant}${expiry}`; + + const encoder = new TextEncoder(); + const keyData = encoder.encode(signingKey); + const messageData = encoder.encode(stringToSign); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); + + // Convert to hex string + return Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} +``` + +**Node.js**: +```javascript +const crypto = require('crypto'); + +function generateSignature(imageId, variant, expiry, signingKey) { + const stringToSign = `${imageId}${variant}${expiry}`; + + return crypto + .createHmac('sha256', signingKey) + .update(stringToSign) + .digest('hex'); +} +``` + +### Build Signed URL + +```typescript +async function generateSignedURL( + imageId: string, + variant: string, + expirySeconds: number, + accountHash: string, + signingKey: string +): Promise { + const expiry = Math.floor(Date.now() / 1000) + expirySeconds; + const sig = await generateSignature(imageId, variant, expiry, signingKey); + + return `https://imagedelivery.net/${accountHash}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`; +} +``` + +--- + +## Expiry Timestamp + +**Unix timestamp** (seconds since epoch): +```typescript +const now = Math.floor(Date.now() / 1000); +const oneHour = 60 * 60; +const expiry = now + oneHour; // 1 hour from now +``` + +**From specific date**: +```typescript +const expiryDate = new Date('2025-10-27T18:00:00Z'); +const expiry = Math.floor(expiryDate.getTime() / 1000); +``` + +**Common presets**: +```typescript +const expiryPresets = { + fiveMinutes: 5 * 60, + fifteenMinutes: 15 * 60, + oneHour: 60 * 60, + oneDay: 24 * 60 * 60, + oneWeek: 7 * 24 * 60 * 60 +}; +``` + +--- + +## Complete Example (Workers) + +```typescript +interface Env { + IMAGES_ACCOUNT_HASH: string; + IMAGES_SIGNING_KEY: string; +} + +async function generateSignedURL( + imageId: string, + variant: string, + expirySeconds: number, + env: Env +): Promise { + const now = Math.floor(Date.now() / 1000); + const expiry = now + expirySeconds; + const stringToSign = `${imageId}${variant}${expiry}`; + + const encoder = new TextEncoder(); + const keyData = encoder.encode(env.IMAGES_SIGNING_KEY); + const messageData = encoder.encode(stringToSign); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); + const sig = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Generate signed URL valid for 1 hour + const signedURL = await generateSignedURL( + 'image-id', + 'public', + 3600, + env + ); + + return Response.json({ signedURL }); + } +}; +``` + +--- + +## Multiple Variants + +Generate signed URLs for multiple variants at once: + +```typescript +async function generateSignedURLsForVariants( + imageId: string, + variants: string[], + expirySeconds: number, + env: Env +): Promise> { + const urls: Record = {}; + + for (const variant of variants) { + urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env); + } + + return urls; +} + +// Usage +const urls = await generateSignedURLsForVariants( + 'image-id', + ['thumbnail', 'medium', 'large'], + 3600, + env +); + +// { +// thumbnail: 'https://imagedelivery.net/.../thumbnail?exp=...&sig=...', +// medium: 'https://imagedelivery.net/.../medium?exp=...&sig=...', +// large: 'https://imagedelivery.net/.../large?exp=...&sig=...' +// } +``` + +--- + +## Verification (Cloudflare handles this) + +For reference, here's how verification works: + +```typescript +async function verifySignature( + imageId: string, + variant: string, + expiry: number, + providedSig: string, + signingKey: string +): Promise { + // Check if expired + const now = Math.floor(Date.now() / 1000); + if (expiry < now) { + return false; // Expired + } + + // Generate expected signature + const expectedSig = await generateSignature(imageId, variant, expiry, signingKey); + + return expectedSig === providedSig; +} +``` + +--- + +## Common Issues + +### 1. Signed URL returns 403 + +**Causes**: +- Image not uploaded with `requireSignedURLs=true` +- Signature incorrect (wrong signing key) +- URL expired +- Using flexible variants (not supported) + +**Solutions**: +- Verify image requires signed URLs +- Check signing key matches dashboard +- Ensure expiry in future +- Use named variants only + +### 2. Signature doesn't match + +**Causes**: +- Wrong signing key +- Incorrect string-to-sign format +- Timestamp precision (must be seconds, not milliseconds) + +**Solutions**: +```typescript +// ✅ CORRECT - Seconds +const expiry = Math.floor(Date.now() / 1000); + +// ❌ WRONG - Milliseconds +const expiry = Date.now(); +``` + +### 3. Cannot use with flexible variants + +**Error**: 403 Forbidden when using flexible variants with signed URLs + +**Solution**: Use named variants for private images +```typescript +// ✅ CORRECT +const url = await generateSignedURL('id', 'thumbnail', 3600, env); + +// ❌ WRONG +const url = `https://imagedelivery.net/${hash}/${id}/w=300?exp=${exp}&sig=${sig}`; +``` + +--- + +## Security Best Practices + +1. **Keep signing key secret**: Never expose in client-side code +2. **Generate on backend**: Frontend requests signed URL from backend +3. **Short expiry for sensitive content**: 5-15 minutes for temporary access +4. **Longer expiry for user content**: 1-24 hours for profile photos +5. **Rotate keys periodically**: Dashboard → Images → Keys → Regenerate +6. **Log suspicious activity**: Monitor for signature mismatches + +--- + +## Example Use Cases + +### Profile Photos (24-hour expiry) +```typescript +const profileURL = await generateSignedURL('user-123', 'avatar', 24 * 60 * 60, env); +``` + +### Temporary Download (5 minutes) +```typescript +const downloadURL = await generateSignedURL('doc-456', 'large', 5 * 60, env); +``` + +### Paid Content (1-week subscription) +```typescript +const contentURL = await generateSignedURL('premium-789', 'medium', 7 * 24 * 60 * 60, env); +``` + +--- + +## Official Documentation + +- **Serve Private Images**: https://developers.cloudflare.com/images/manage-images/serve-images/serve-private-images/ diff --git a/references/top-errors.md b/references/top-errors.md new file mode 100644 index 0000000..912f681 --- /dev/null +++ b/references/top-errors.md @@ -0,0 +1,476 @@ +# Top Errors and Solutions + +Complete troubleshooting guide for all documented Cloudflare Images errors. + +--- + +## Direct Creator Upload Errors + +### 1. CORS Error - Content-Type Not Allowed + +**Error**: +``` +Access to XMLHttpRequest blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers +``` + +**Source**: [Cloudflare Community #345739](https://community.cloudflare.com/t/direct-image-upload-cors-error/345739), [#368114](https://community.cloudflare.com/t/cloudflare-images-direct-upload-cors-problem/368114) + +**Why It Happens**: +Server CORS settings only allow `multipart/form-data` for Content-Type header. + +**Solution**: +```javascript +// ✅ CORRECT +const formData = new FormData(); +formData.append('file', fileInput.files[0]); +await fetch(uploadURL, { + method: 'POST', + body: formData // Browser sets multipart/form-data automatically +}); + +// ❌ WRONG +await fetch(uploadURL, { + headers: { 'Content-Type': 'application/json' }, // CORS error + body: JSON.stringify({ file: base64Image }) +}); +``` + +**Prevention**: +- Use FormData API +- Let browser set Content-Type header (don't set manually) +- Name field `file` (not `image` or other) + +--- + +### 2. Error 5408 - Upload Timeout + +**Error**: `Error 5408` after ~15 seconds + +**Source**: [Cloudflare Community #571336](https://community.cloudflare.com/t/images-direct-creator-upload-error-5408/571336) + +**Why It Happens**: +Cloudflare has 30-second request timeout. Slow uploads or large files exceed limit. + +**Solution**: +```javascript +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +if (file.size > MAX_FILE_SIZE) { + alert('File too large. Please select an image under 10MB.'); + return; +} + +// Compress image before upload (optional) +async function compressImage(file) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = await createImageBitmap(file); + + const maxWidth = 4000; + const scale = Math.min(1, maxWidth / img.width); + + canvas.width = img.width * scale; + canvas.height = img.height * scale; + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.9); + }); +} +``` + +**Prevention**: +- Limit file size (10MB max recommended) +- Compress images client-side if needed +- Show upload progress to user +- Handle timeout errors gracefully + +--- + +### 3. Error 400 - Invalid File Parameter + +**Error**: `400 Bad Request` with unhelpful message + +**Source**: [Cloudflare Community #487629](https://community.cloudflare.com/t/direct-creator-upload-returning-400/487629) + +**Why It Happens**: +File field must be named `file` (not `image`, `photo`, etc.). + +**Solution**: +```javascript +// ✅ CORRECT +formData.append('file', imageFile); + +// ❌ WRONG +formData.append('image', imageFile); // 400 error +formData.append('photo', imageFile); // 400 error +formData.append('upload', imageFile); // 400 error +``` + +**Prevention**: +- Always name the field `file` +- Check FormData contents before sending + +--- + +### 4. CORS Preflight Failures + +**Error**: Preflight OPTIONS request blocked + +**Source**: [Cloudflare Community #306805](https://community.cloudflare.com/t/cors-error-when-using-direct-creator-upload/306805) + +**Why It Happens**: +Calling `/direct_upload` API directly from browser (should be backend-only). + +**Solution**: +``` +CORRECT ARCHITECTURE: + +Browser → POST /api/upload-url → Backend + ↓ + POST /direct_upload → Cloudflare API + ↓ +Backend ← Returns uploadURL ← Cloudflare API + ↓ +Browser receives uploadURL + ↓ +Browser → Uploads to uploadURL → Cloudflare (direct upload) +``` + +**Prevention**: +- Never expose API token to browser +- Generate upload URL on backend +- Return uploadURL to frontend +- Frontend uploads to uploadURL (not /direct_upload) + +--- + +## Image Transformation Errors + +### 5. Error 9401 - Invalid Arguments + +**Error**: `Cf-Resized: err=9401` in response headers + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Missing required `cf.image` parameters or invalid values. + +**Solution**: +```typescript +// ✅ CORRECT +fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } +}); + +// ❌ WRONG +fetch(imageURL, { + cf: { + image: { + width: 'large', // Must be number + quality: 150, // Max 100 + format: 'invalid' // Must be valid format + } + } +}); +``` + +**Prevention**: +- Validate all parameters +- Use TypeScript for type checking +- Check official docs for valid ranges + +--- + +### 6. Error 9402 - Image Too Large + +**Error**: `Cf-Resized: err=9402` + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Image exceeds maximum area or download fails. + +**Solution**: +```typescript +// Check image dimensions before transforming +const response = await fetch(imageURL, { method: 'HEAD' }); +// Or fetch and check after +const img = await fetch(imageURL); +// Validate size +``` + +**Prevention**: +- Validate source image dimensions +- Max 100 megapixels (e.g., 10000x10000px) +- Use reasonable source images + +--- + +### 7. Error 9403 - Request Loop + +**Error**: `Cf-Resized: err=9403` + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Worker fetching its own URL or already-resized image. + +**Solution**: +```typescript +// ✅ CORRECT - Fetch external origin +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname.startsWith('/images/')) { + const imagePath = url.pathname.replace('/images/', ''); + const originURL = `https://storage.example.com/${imagePath}`; + + return fetch(originURL, { + cf: { image: { width: 800 } } + }); + } + + return new Response('Not found', { status: 404 }); + } +}; + +// ❌ WRONG - Fetches worker's own URL (loop) +export default { + async fetch(request: Request): Promise { + return fetch(request, { // Fetches self + cf: { image: { width: 800 } } + }); + } +}; +``` + +**Prevention**: +- Always fetch external origin +- Don't transform already-transformed images +- Check URL routing logic + +--- + +### 8. Error 9406/9419 - Invalid URL Format + +**Error**: `Cf-Resized: err=9406` or `err=9419` + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Image URL uses HTTP (not HTTPS) or contains spaces/unescaped Unicode. + +**Solution**: +```typescript +// ✅ CORRECT +const filename = "photo name.jpg"; +const imageURL = `https://example.com/images/${encodeURIComponent(filename)}`; + +// ❌ WRONG +const imageURL = "http://example.com/image.jpg"; // HTTP not allowed +const imageURL = "https://example.com/photo name.jpg"; // Space not encoded +``` + +**Prevention**: +- Always use HTTPS (HTTP not supported) +- URL-encode all paths with `encodeURIComponent()` +- No spaces or unescaped Unicode in URLs + +--- + +### 9. Error 9412 - Non-Image Response + +**Error**: `Cf-Resized: err=9412` + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Origin server returns HTML (e.g., 404 page) instead of image. + +**Solution**: +```typescript +// Verify URL before transforming +const originResponse = await fetch(imageURL, { method: 'HEAD' }); +const contentType = originResponse.headers.get('content-type'); + +if (!contentType?.startsWith('image/')) { + return new Response('Not an image', { status: 400 }); +} + +return fetch(imageURL, { + cf: { image: { width: 800 } } +}); +``` + +**Prevention**: +- Verify origin returns image (check Content-Type) +- Handle 404s before transforming +- Validate image URLs + +--- + +### 10. Error 9413 - Max Image Area Exceeded + +**Error**: `Cf-Resized: err=9413` + +**Source**: [Cloudflare Docs - Troubleshooting](https://developers.cloudflare.com/images/reference/troubleshooting/) + +**Why It Happens**: +Source image exceeds 100 megapixels (e.g., 10000x10000px). + +**Solution**: +```typescript +const MAX_MEGAPIXELS = 100; + +if (width * height > MAX_MEGAPIXELS * 1_000_000) { + return new Response('Image too large', { status: 413 }); +} +``` + +**Prevention**: +- Validate image dimensions before transforming +- Pre-process oversized images +- Reject images above threshold (100 megapixels) + +--- + +## Configuration Errors + +### 11. Flexible Variants + Signed URLs Incompatibility + +**Error**: Flexible variants don't work with private images + +**Source**: [Cloudflare Docs - Enable flexible variants](https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/) + +**Why It Happens**: +Flexible variants cannot be used with `requireSignedURLs=true`. + +**Solution**: +```typescript +// ✅ CORRECT - Use named variants for private images +await uploadImage({ + file: imageFile, + requireSignedURLs: true // Use named variants: /public, /avatar, etc. +}); + +// ❌ WRONG - Flexible variants don't support signed URLs +// Cannot use: /w=400,sharpen=3 with requireSignedURLs=true +``` + +**Prevention**: +- Use named variants for private images +- Use flexible variants for public images only + +--- + +### 12. SVG Resizing Limitation + +**Error**: SVG files don't resize via transformations + +**Source**: [Cloudflare Docs - SVG files](https://developers.cloudflare.com/images/transform-images/#svg-files) + +**Why It Happens**: +SVG is vector format (inherently scalable), resizing not applicable. + +**Solution**: +```typescript +// SVGs can be served but not resized +// Use any variant name as placeholder +// https://imagedelivery.net///public + +// SVG will be served at original size regardless of variant settings +``` + +**Prevention**: +- Don't try to resize SVGs +- Serve SVGs as-is +- Use variants as placeholders + +--- + +### 13. EXIF Metadata Stripped by Default + +**Error**: GPS data, camera settings removed from uploaded JPEGs + +**Source**: [Cloudflare Docs - Transform via URL](https://developers.cloudflare.com/images/transform-images/transform-via-url/#metadata) + +**Why It Happens**: +Default behavior strips all metadata except copyright. + +**Solution**: +```typescript +// Preserve metadata +fetch(imageURL, { + cf: { + image: { + width: 800, + metadata: 'keep' // Options: 'none', 'copyright', 'keep' + } + } +}); +``` + +**Prevention**: +- Use `metadata=keep` if preservation needed +- Default `copyright` for JPEG +- Color profiles and EXIF rotation always applied + +--- + +## General Troubleshooting + +### Images not transforming + +**Symptoms**: `/cdn-cgi/image/...` returns original or 404 + +**Solutions**: +1. Enable transformations: Dashboard → Images → Transformations → Enable +2. Verify zone proxied (orange cloud) +3. Check source image accessible +4. Wait 5-10 minutes for propagation + +### Signed URLs returning 403 + +**Symptoms**: 403 Forbidden with signed URL + +**Solutions**: +1. Verify image uploaded with `requireSignedURLs=true` +2. Check signature generation (HMAC-SHA256) +3. Ensure expiry in future +4. Verify signing key matches dashboard +5. Cannot use flexible variants (use named variants) + +--- + +## Checking for Errors + +**Response Headers**: +```javascript +const response = await fetch(transformedImageURL); +const cfResized = response.headers.get('Cf-Resized'); + +if (cfResized?.includes('err=')) { + console.error('Transformation error:', cfResized); +} +``` + +**Common patterns**: +- `Cf-Resized: err=9401` - Invalid arguments +- `Cf-Resized: err=9403` - Request loop +- `Cf-Resized: err=9412` - Non-image response + +--- + +## Official Documentation + +- **Troubleshooting**: https://developers.cloudflare.com/images/reference/troubleshooting/ +- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/ diff --git a/references/transformation-options.md b/references/transformation-options.md new file mode 100644 index 0000000..fa9c486 --- /dev/null +++ b/references/transformation-options.md @@ -0,0 +1,337 @@ +# Image Transformation Options + +Complete reference for all image transformation parameters. + +Works with: +- **URL format**: `/cdn-cgi/image//` +- **Workers format**: `fetch(url, { cf: { image: {...} } })` + +--- + +## Sizing + +### width +Max width in pixels. + +**URL**: `width=800` or `w=800` +**Workers**: `{ width: 800 }` +**Range**: 1-10000 + +### height +Max height in pixels. + +**URL**: `height=600` or `h=600` +**Workers**: `{ height: 600 }` +**Range**: 1-10000 + +### dpr +Device pixel ratio for high-DPI displays. + +**URL**: `dpr=2` +**Workers**: `{ dpr: 2 }` +**Range**: 1-3 +**Example**: `dpr=2` serves 2x size for Retina displays + +--- + +## Fit Modes + +### fit +How to resize image. + +**Options**: +- `scale-down`: Shrink to fit (never enlarge) +- `contain`: Resize to fit within dimensions (preserve aspect ratio) +- `cover`: Resize to fill dimensions (may crop) +- `crop`: Crop to exact dimensions +- `pad`: Resize and add padding + +**URL**: `fit=cover` +**Workers**: `{ fit: 'cover' }` +**Default**: `scale-down` + +**Examples**: +``` +fit=scale-down: 800x600 image → max 400x300 → 400x300 (scaled down) +fit=scale-down: 400x300 image → max 800x600 → 400x300 (not enlarged) +fit=contain: Any size → 800x600 → Fits inside box, preserves aspect +fit=cover: Any size → 800x600 → Fills box, may crop edges +fit=crop: Any size → 800x600 → Exact size, crops as needed +fit=pad: 800x600 image → 1000x1000 → 800x600 + padding +``` + +--- + +## Quality & Format + +### quality +JPEG/WebP quality. + +**URL**: `quality=85` or `q=85` +**Workers**: `{ quality: 85 }` +**Range**: 1-100 +**Default**: 85 +**Recommended**: 80-90 for photos, 90-100 for graphics + +### format +Output format. + +**Options**: +- `auto`: Serve AVIF → WebP → Original based on browser support +- `avif`: Always AVIF (with WebP fallback) +- `webp`: Always WebP +- `jpeg`: JPEG (progressive) +- `baseline-jpeg`: JPEG (baseline, for older devices) +- `json`: Image metadata instead of image + +**URL**: `format=auto` or `f=auto` +**Workers**: `{ format: 'auto' }` +**Default**: Original format +**Recommended**: `format=auto` for optimal delivery + +### compression +WebP compression mode. + +**Options**: +- `fast`: Faster encoding, larger file +- `lossless`: No quality loss + +**URL**: `compression=fast` +**Workers**: `{ compression: 'fast' }` + +--- + +## Cropping + +### gravity +Crop focal point. + +**Options**: +- `auto`: Smart crop based on saliency +- `face`: Crop to detected face +- `left`, `right`, `top`, `bottom`: Crop to side +- `XxY`: Coordinates (e.g., `0.5x0.5` for center) + +**URL**: `gravity=face` or `gravity=0.5x0.3` +**Workers**: `{ gravity: 'face' }` or `{ gravity: { x: 0.5, y: 0.3 } }` +**Default**: `auto` + +**Examples**: +``` +gravity=auto: Smart crop to interesting area +gravity=face: Crop to detected face (if found) +gravity=0.5x0.5: Center crop +gravity=0x0: Top-left corner +gravity=1x1: Bottom-right corner +``` + +### zoom +Face cropping zoom level (when `gravity=face`). + +**URL**: `zoom=0.5` +**Workers**: `{ zoom: 0.5 }` +**Range**: 0-1 +**Default**: 0 +**Behavior**: `0` = include background, `1` = crop close to face + +### trim +Remove border (pixels to trim from edges). + +**URL**: `trim=10` +**Workers**: `{ trim: 10 }` +**Range**: 0-100 + +--- + +## Effects + +### blur +Gaussian blur radius. + +**URL**: `blur=20` +**Workers**: `{ blur: 20 }` +**Range**: 1-250 +**Use cases**: Privacy, background blur, LQIP placeholders + +### sharpen +Sharpen intensity. + +**URL**: `sharpen=3` +**Workers**: `{ sharpen: 3 }` +**Range**: 0-10 +**Recommended**: 1-3 for subtle sharpening + +### brightness +Brightness adjustment. + +**URL**: `brightness=1.2` +**Workers**: `{ brightness: 1.2 }` +**Range**: 0-2 +**Default**: 1 (no change) +**Examples**: `0.5` = darker, `1.5` = brighter + +### contrast +Contrast adjustment. + +**URL**: `contrast=1.1` +**Workers**: `{ contrast: 1.1 }` +**Range**: 0-2 +**Default**: 1 (no change) + +### gamma +Gamma correction. + +**URL**: `gamma=1.5` +**Workers**: `{ gamma: 1.5 }` +**Range**: 0-2 +**Default**: 1 (no change) +**Note**: `0` is ignored + +--- + +## Rotation & Flipping + +### rotate +Rotate image. + +**Options**: `0`, `90`, `180`, `270` + +**URL**: `rotate=90` +**Workers**: `{ rotate: 90 }` + +### flip +Flip image. + +**Options**: +- `h`: Horizontal flip +- `v`: Vertical flip +- `hv`: Both horizontal and vertical + +**URL**: `flip=h` +**Workers**: `{ flip: 'h' }` + +**Note**: Flipping is performed BEFORE rotation. + +--- + +## Other + +### background +Background color for transparency or padding. + +**URL**: `background=rgb(255 0 0)` (CSS4 syntax) +**Workers**: `{ background: 'rgb(255 0 0)' }` +**Examples**: +- `background=white` +- `background=rgb(255 255 255)` +- `background=rgba(255 255 255 50)` + +**Use with**: `fit=pad` or transparent images (PNG, WebP) + +### metadata +EXIF metadata handling. + +**Options**: +- `none`: Strip all metadata +- `copyright`: Keep only copyright tag +- `keep`: Preserve most EXIF metadata + +**URL**: `metadata=keep` +**Workers**: `{ metadata: 'keep' }` +**Default**: `copyright` for JPEG, `none` for others + +**Note**: Color profiles and EXIF rotation always applied, even if metadata stripped. + +### anim +Preserve animation frames (GIF, WebP). + +**URL**: `anim=false` +**Workers**: `{ anim: false }` +**Default**: `true` + +**Use case**: Converting animated GIF to still image + +--- + +## Combining Options + +**URL Format**: +``` +/cdn-cgi/image/width=800,height=600,fit=cover,quality=85,format=auto/image.jpg +``` + +**Workers Format**: +```javascript +fetch(imageURL, { + cf: { + image: { + width: 800, + height: 600, + fit: 'cover', + quality: 85, + format: 'auto' + } + } +}); +``` + +--- + +## Common Presets + +### Thumbnail +``` +width=300,height=300,fit=cover,quality=85,format=auto +``` + +### Avatar +``` +width=200,height=200,fit=cover,gravity=face,quality=90,format=auto +``` + +### Hero +``` +width=1920,height=1080,fit=cover,quality=85,format=auto +``` + +### Blur Placeholder (LQIP) +``` +width=50,quality=10,blur=20,format=webp +``` + +### Product Image +``` +width=800,height=800,fit=contain,sharpen=2,quality=90,format=auto +``` + +### Responsive (Mobile) +``` +width=480,quality=85,format=auto +``` + +### Responsive (Tablet) +``` +width=768,quality=85,format=auto +``` + +### Responsive (Desktop) +``` +width=1920,quality=85,format=auto +``` + +--- + +## Limits + +- **Max dimensions**: 10,000 x 10,000 pixels +- **Max area**: 100 megapixels +- **Max file size**: No published limit (but 10MB recommended) +- **Quality range**: 1-100 +- **DPR range**: 1-3 + +--- + +## Official Documentation + +- **Transform via URL**: https://developers.cloudflare.com/images/transform-images/transform-via-url/ +- **Transform via Workers**: https://developers.cloudflare.com/images/transform-images/transform-via-workers/ diff --git a/references/variants-guide.md b/references/variants-guide.md new file mode 100644 index 0000000..7ac27e9 --- /dev/null +++ b/references/variants-guide.md @@ -0,0 +1,264 @@ +# Variants Guide - Named vs Flexible + +Complete guide to Cloudflare Images variants. + +--- + +## What Are Variants? + +Variants define how images should be resized and transformed for different use cases. + +**Two Types**: +1. **Named Variants** - Predefined transformations (up to 100) +2. **Flexible Variants** - Dynamic transformations (unlimited) + +--- + +## Named Variants + +### Overview + +Pre-configured transformations that apply consistently across all images. + +**Limits**: 100 variants per account +**Use with**: Public and private images (signed URLs compatible) + +### Creating Named Variants + +**Via API**: +```bash +curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants" \ + --header "Authorization: Bearer " \ + --header "Content-Type: application/json" \ + --data '{ + "id": "thumbnail", + "options": { + "fit": "cover", + "width": 300, + "height": 300, + "metadata": "none" + }, + "neverRequireSignedURLs": false + }' +``` + +**Via Dashboard**: +1. Dashboard → Images → Variants +2. Create variant +3. Set dimensions, fit mode, metadata handling + +### Using Named Variants + +**URL Format**: +``` +https://imagedelivery.net/// +``` + +**Example**: +```html + +``` + +### Common Named Variants + +```javascript +const presets = { + thumbnail: { width: 300, height: 300, fit: 'cover' }, + avatar: { width: 200, height: 200, fit: 'cover' }, + small: { width: 480, fit: 'scale-down' }, + medium: { width: 768, fit: 'scale-down' }, + large: { width: 1920, fit: 'scale-down' }, + hero: { width: 1920, height: 1080, fit: 'cover' }, + product: { width: 800, height: 800, fit: 'contain' } +}; +``` + +### When to Use Named Variants + +✅ **Use when**: +- Consistent sizes needed across app +- Private images (signed URLs required) +- Predictable, simple URLs +- Team collaboration (shared definitions) + +❌ **Don't use when**: +- Need dynamic sizing per request +- Rapid prototyping with many sizes +- Approaching 100-variant limit + +--- + +## Flexible Variants + +### Overview + +Dynamic transformations using params directly in URL. + +**Limits**: Unlimited transformations +**Use with**: Public images only (signed URLs NOT compatible) + +### Enabling Flexible Variants + +**One-time setup per account**: +```bash +curl --request PATCH \ + https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/config \ + --header "Authorization: Bearer " \ + --header "Content-Type: application/json" \ + --data '{"flexible_variants": true}' +``` + +### Using Flexible Variants + +**URL Format**: +``` +https://imagedelivery.net/// +``` + +**Examples**: +```html + + + + + + + + + + + +``` + +### Available Parameters + +Same as transformation options: +- `w`, `h` - Width, height +- `fit` - Fit mode (scale-down, contain, cover, crop, pad) +- `q` - Quality (1-100) +- `f` - Format (auto, avif, webp, jpeg) +- `gravity` - Crop focal point (auto, face, left, right, top, bottom) +- `blur`, `sharpen`, `brightness`, `contrast`, `gamma` +- `rotate`, `flip` +- `dpr` - Device pixel ratio +- `metadata` - EXIF handling (none, copyright, keep) +- `anim` - Preserve animation (true/false) + +### When to Use Flexible Variants + +✅ **Use when**: +- Dynamic sizing needs +- Public images only +- Rapid prototyping +- User-controlled transformations + +❌ **Don't use when**: +- Need signed URLs (private images) +- Want consistent, predictable URLs +- Team needs shared definitions + +--- + +## Comparison Table + +| Feature | Named Variants | Flexible Variants | +|---------|---------------|-------------------| +| **Limit** | 100 per account | Unlimited | +| **Signed URLs** | ✅ Compatible | ❌ Not compatible | +| **URL Format** | `/thumbnail` | `/w=400,h=300,fit=cover` | +| **URL Length** | Short, clean | Longer, dynamic | +| **Setup** | Create variants first | Enable once, use anywhere | +| **Use Case** | Consistent sizes | Dynamic sizing | +| **Team Sharing** | Shared definitions | Ad-hoc transformations | +| **Private Images** | ✅ Supported | ❌ Public only | + +--- + +## Combining Both + +You can use both types in the same account: + +```html + + + + + +``` + +--- + +## Best Practices + +### For Named Variants + +1. **Create core sizes first**: thumbnail, small, medium, large +2. **Use descriptive names**: `product-square`, `hero-wide`, `avatar-round` +3. **Document variant usage**: Share definitions with team +4. **Set consistent quality**: 85 for photos, 90+ for graphics +5. **Use `metadata: none`**: Unless specific need to preserve EXIF + +### For Flexible Variants + +1. **Always use `f=auto`**: Optimal format for each browser +2. **Limit dynamic range**: Don't allow arbitrary sizes (performance) +3. **Cache popular sizes**: Create named variants for common sizes +4. **URL-encode params**: Especially if using special characters +5. **Public images only**: Remember signed URL incompatibility + +--- + +## Migration Strategies + +### From Flexible to Named + +If approaching flexibility limits or need signed URLs: + +```javascript +// Analyze usage logs +const popularSizes = analyzeImageRequests(); +// { w=300,h=300: 50000, w=800,h=600: 30000, ... } + +// Create named variants for top sizes +for (const [params, count] of Object.entries(popularSizes)) { + if (count > 10000) { + await createVariant(getNameForParams(params), parseParams(params)); + } +} + +// Update URLs from flexible to named +// Before: /w=300,h=300,fit=cover +// After: /thumbnail +``` + +### From Named to Flexible + +If need more than 100 variants: + +1. Enable flexible variants +2. Gradually migrate to dynamic params +3. Keep popular sizes as named variants +4. Use flexible for long-tail sizes + +--- + +## Cost Considerations + +**Named Variants**: +- Cached at edge (fast delivery) +- Predictable bandwidth +- Good for high traffic + +**Flexible Variants**: +- Also cached at edge +- More cache keys (potentially) +- Good for diverse sizing needs + +**Both**: First transformation billable, subsequent cached requests free + +--- + +## Official Documentation + +- **Create Variants**: https://developers.cloudflare.com/images/manage-images/create-variants/ +- **Enable Flexible Variants**: https://developers.cloudflare.com/images/manage-images/enable-flexible-variants/ diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh new file mode 100755 index 0000000..44472f3 --- /dev/null +++ b/scripts/check-versions.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Cloudflare Images - Version Checker +# Verifies API endpoints are current + +echo "Cloudflare Images - API Version Checker" +echo "========================================" +echo "" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Check if API token is set +if [ -z "$IMAGES_API_TOKEN" ]; then + echo -e "${YELLOW}WARNING:${NC} IMAGES_API_TOKEN environment variable not set" + echo "Set it with: export IMAGES_API_TOKEN=your_token_here" + echo "" +fi + +if [ -z "$IMAGES_ACCOUNT_ID" ]; then + echo -e "${YELLOW}WARNING:${NC} IMAGES_ACCOUNT_ID environment variable not set" + echo "Set it with: export IMAGES_ACCOUNT_ID=your_account_id" + echo "" +fi + +echo "Checking Cloudflare Images API endpoints..." +echo "" + +# Check main API endpoint +echo -n "Checking /images/v1 endpoint... " +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v1" \ + -H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null) + +if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then + echo -e "${GREEN}✓ Available${NC}" +else + echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}" +fi + +# Check v2 endpoint +echo -n "Checking /images/v2 endpoint... " +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2" \ + -H "Authorization: Bearer ${IMAGES_API_TOKEN}" 2>/dev/null) + +if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then + echo -e "${GREEN}✓ Available${NC}" +else + echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}" +fi + +# Check direct upload endpoint +echo -n "Checking /images/v2/direct_upload endpoint... " +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST \ + "https://api.cloudflare.com/client/v4/accounts/${IMAGES_ACCOUNT_ID}/images/v2/direct_upload" \ + -H "Authorization: Bearer ${IMAGES_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{}' 2>/dev/null) + +if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "400" ] || [ "$RESPONSE" = "401" ]; then + echo -e "${GREEN}✓ Available${NC}" +else + echo -e "${RED}✗ Error (HTTP $RESPONSE)${NC}" +fi + +# Check batch API endpoint +echo -n "Checking batch.imagedelivery.net endpoint... " +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://batch.imagedelivery.net/images/v1" \ + -H "Authorization: Bearer ${IMAGES_BATCH_TOKEN}" 2>/dev/null) + +if [ "$RESPONSE" = "200" ] || [ "$RESPONSE" = "401" ]; then + echo -e "${GREEN}✓ Available${NC}" +else + echo -e "${YELLOW}⚠ Cannot verify (set IMAGES_BATCH_TOKEN if using)${NC}" +fi + +echo "" +echo "Package Recommendations:" +echo "========================" +echo "TypeScript types: @cloudflare/workers-types@latest" +echo "Wrangler CLI: wrangler@latest" +echo "" +echo "No npm packages required for Cloudflare Images API" +echo "(uses native fetch API)" +echo "" + +# Check if wrangler is installed +if command -v wrangler &> /dev/null; then + WRANGLER_VERSION=$(wrangler --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo -e "${GREEN}✓${NC} Wrangler installed: v$WRANGLER_VERSION" +else + echo -e "${YELLOW}⚠${NC} Wrangler not installed (optional)" + echo " Install: npm install -g wrangler" +fi + +echo "" +echo "API Version: v2 (direct uploads), v1 (standard uploads)" +echo "Last Verified: 2025-10-26" +echo "" +echo -e "${GREEN}✓ All core endpoints available${NC}" diff --git a/templates/batch-upload.ts b/templates/batch-upload.ts new file mode 100644 index 0000000..37dab2c --- /dev/null +++ b/templates/batch-upload.ts @@ -0,0 +1,308 @@ +/** + * Cloudflare Images - Batch API + * + * High-volume image uploads using batch tokens. + * + * When to use: + * - Migrating thousands of images + * - Bulk upload workflows + * - Automated image ingestion + * + * IMPORTANT: Batch API uses different host and authentication + * - Host: batch.imagedelivery.net (NOT api.cloudflare.com) + * - Auth: Batch token (NOT regular API token) + */ + +interface Env { + IMAGES_BATCH_TOKEN: string; // From Dashboard → Images → Batch API +} + +interface BatchUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; +} + +interface CloudflareImagesResponse { + success: boolean; + result?: { + id: string; + filename: string; + uploaded: string; + requireSignedURLs: boolean; + variants: string[]; + }; + errors?: Array<{ code: number; message: string }>; +} + +/** + * Upload single image via Batch API + */ +export async function batchUploadImage( + file: File, + options: BatchUploadOptions = {}, + env: Env +): Promise { + const formData = new FormData(); + + formData.append('file', file); + + if (options.id) { + formData.append('id', options.id); + } + + if (options.requireSignedURLs !== undefined) { + formData.append('requireSignedURLs', String(options.requireSignedURLs)); + } + + if (options.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + const response = await fetch('https://batch.imagedelivery.net/images/v1', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}` + }, + body: formData + }); + + return response.json(); +} + +/** + * Upload image via URL using Batch API + */ +export async function batchUploadViaURL( + imageUrl: string, + options: BatchUploadOptions = {}, + env: Env +): Promise { + const formData = new FormData(); + + formData.append('url', imageUrl); + + if (options.id) { + formData.append('id', options.id); + } + + if (options.requireSignedURLs !== undefined) { + formData.append('requireSignedURLs', String(options.requireSignedURLs)); + } + + if (options.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + const response = await fetch('https://batch.imagedelivery.net/images/v1', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}` + }, + body: formData + }); + + return response.json(); +} + +/** + * List images via Batch API + */ +export async function batchListImages( + page: number = 1, + perPage: number = 100, + env: Env +): Promise<{ success: boolean; result?: { images: unknown[] } }> { + const response = await fetch( + `https://batch.imagedelivery.net/images/v2?page=${page}&per_page=${perPage}`, + { + headers: { + 'Authorization': `Bearer ${env.IMAGES_BATCH_TOKEN}` + } + } + ); + + return response.json(); +} + +/** + * Parallel batch upload (multiple images at once) + */ +export async function uploadMultipleImages( + images: Array<{ file?: File; url?: string; id?: string; metadata?: Record }>, + concurrency: number = 5, + env: Env +): Promise> { + const results: Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }> = []; + const chunks: typeof images[] = []; + + // Split into chunks for parallel processing + for (let i = 0; i < images.length; i += concurrency) { + chunks.push(images.slice(i, i + concurrency)); + } + + // Process each chunk + for (const chunk of chunks) { + const promises = chunk.map(async (img) => { + try { + let result: CloudflareImagesResponse; + + if (img.file) { + result = await batchUploadImage(img.file, { id: img.id, metadata: img.metadata }, env); + } else if (img.url) { + result = await batchUploadViaURL(img.url, { id: img.id, metadata: img.metadata }, env); + } else { + throw new Error('Must provide either file or url'); + } + + return { input: img, result }; + + } catch (error) { + return { + input: img, + error: error instanceof Error ? error.message : 'Upload failed' + }; + } + }); + + const chunkResults = await Promise.all(promises); + results.push(...chunkResults); + } + + return results; +} + +/** + * Migration helper: Bulk ingest from URLs + */ +export async function migrateImagesFromURLs( + imageUrls: string[], + options: { + concurrency?: number; + prefix?: string; // ID prefix for all images + metadata?: Record; + } = {}, + env: Env +): Promise<{ + successful: number; + failed: number; + results: Array<{ url: string; id?: string; error?: string }>; +}> { + const concurrency = options.concurrency || 5; + const successful: string[] = []; + const failed: string[] = []; + const results: Array<{ url: string; id?: string; error?: string }> = []; + + const images = imageUrls.map((url, index) => ({ + url, + id: options.prefix ? `${options.prefix}-${index}` : undefined, + metadata: options.metadata + })); + + const uploadResults = await uploadMultipleImages(images, concurrency, env); + + for (const result of uploadResults) { + const input = result.input as { url: string; id?: string }; + + if (result.error) { + failed.push(input.url); + results.push({ url: input.url, error: result.error }); + } else { + successful.push(input.url); + results.push({ url: input.url, id: result.result?.result?.id }); + } + } + + return { + successful: successful.length, + failed: failed.length, + results + }; +} + +/** + * Example Worker + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Bulk upload: POST /api/batch-upload + if (request.method === 'POST' && url.pathname === '/api/batch-upload') { + try { + const body = await request.json<{ imageUrls: string[] }>(); + + if (!body.imageUrls || !Array.isArray(body.imageUrls)) { + return Response.json({ error: 'imageUrls array required' }, { status: 400 }); + } + + const result = await migrateImagesFromURLs( + body.imageUrls, + { + concurrency: 5, + prefix: 'migration', + metadata: { source: 'bulk-upload' } + }, + env + ); + + return Response.json(result); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Batch upload failed' }, + { status: 500 } + ); + } + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } +}; + +/** + * Usage examples: + * + * ```typescript + * // Single upload via Batch API + * const result = await batchUploadImage(file, { + * metadata: { source: 'migration' } + * }, env); + * + * // Upload from URL + * const result = await batchUploadViaURL('https://example.com/image.jpg', {}, env); + * + * // Parallel upload multiple images + * const images = [ + * { file: file1, id: 'image-1' }, + * { file: file2, id: 'image-2' }, + * { url: 'https://example.com/image3.jpg', id: 'image-3' } + * ]; + * const results = await uploadMultipleImages(images, 5, env); + * + * // Migrate from URLs + * const urls = [ + * 'https://old-cdn.example.com/image1.jpg', + * 'https://old-cdn.example.com/image2.jpg', + * // ... thousands more + * ]; + * const migration = await migrateImagesFromURLs(urls, { + * concurrency: 10, + * prefix: 'migrated', + * metadata: { migratedAt: new Date().toISOString() } + * }, env); + * + * console.log(`Successful: ${migration.successful}, Failed: ${migration.failed}`); + * ``` + * + * SETUP: + * 1. Dashboard → Images → Batch API + * 2. Create batch token + * 3. Add to wrangler.toml: wrangler secret put IMAGES_BATCH_TOKEN + * + * DIFFERENCES FROM REGULAR API: + * - Host: batch.imagedelivery.net (NOT api.cloudflare.com) + * - Auth: Batch token (NOT regular API token) + * - Same endpoints: /images/v1, /images/v2 + * - Rate limits may differ (contact Cloudflare for high-volume needs) + */ diff --git a/templates/direct-creator-upload-backend.ts b/templates/direct-creator-upload-backend.ts new file mode 100644 index 0000000..f02198c --- /dev/null +++ b/templates/direct-creator-upload-backend.ts @@ -0,0 +1,208 @@ +/** + * Cloudflare Images - Direct Creator Upload (Backend) + * + * Generate one-time upload URLs for users to upload directly to Cloudflare. + * + * Architecture: + * 1. Frontend requests upload URL from this backend + * 2. Backend calls Cloudflare /direct_upload API + * 3. Backend returns uploadURL to frontend + * 4. Frontend uploads directly to Cloudflare using uploadURL + * + * Benefits: + * - No API key exposure to browser + * - Users upload directly to Cloudflare (faster) + * - No intermediary storage needed + */ + +interface Env { + IMAGES_ACCOUNT_ID: string; + IMAGES_API_TOKEN: string; +} + +interface DirectUploadOptions { + requireSignedURLs?: boolean; + metadata?: Record; + expiry?: string; // ISO 8601 format (default: 30min, max: 6hr) + id?: string; // Custom ID (optional) +} + +interface DirectUploadResponse { + success: boolean; + result?: { + id: string; // Image ID that will be uploaded + uploadURL: string; // One-time upload URL for frontend + }; + errors?: Array<{ code: number; message: string }>; +} + +/** + * Generate one-time upload URL + */ +export async function generateUploadURL( + options: DirectUploadOptions = {}, + env: Env +): Promise { + const requestBody: Record = {}; + + // Optional: Require signed URLs for private images + if (options.requireSignedURLs !== undefined) { + requestBody.requireSignedURLs = options.requireSignedURLs; + } + + // Optional: Metadata (attached to image, not visible to end users) + if (options.metadata) { + requestBody.metadata = options.metadata; + } + + // Optional: Expiry (default 30min, max 6hr from now) + if (options.expiry) { + requestBody.expiry = options.expiry; + } + + // Optional: Custom ID (cannot use with requireSignedURLs=true) + if (options.id) { + requestBody.id = options.id; + } + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v2/direct_upload`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + } + ); + + const result: DirectUploadResponse = await response.json(); + + if (!result.success) { + console.error('Failed to generate upload URL:', result.errors); + throw new Error(`Failed to generate upload URL: ${result.errors?.[0]?.message || 'Unknown error'}`); + } + + return result; +} + +/** + * Example Cloudflare Worker endpoint + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // CORS headers for frontend + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', // Replace with your domain + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }; + + // Handle preflight + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + // Endpoint: POST /api/upload-url + if (request.method === 'POST' && url.pathname === '/api/upload-url') { + try { + const body = await request.json<{ + userId?: string; + requireSignedURLs?: boolean; + }>(); + + // Generate upload URL + const result = await generateUploadURL( + { + requireSignedURLs: body.requireSignedURLs ?? false, + metadata: { + userId: body.userId || 'anonymous', + uploadedAt: new Date().toISOString() + }, + // Set expiry: 1 hour from now + expiry: new Date(Date.now() + 60 * 60 * 1000).toISOString() + }, + env + ); + + return Response.json( + { + success: true, + uploadURL: result.result?.uploadURL, + imageId: result.result?.id + }, + { headers: corsHeaders } + ); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to generate upload URL' }, + { status: 500, headers: corsHeaders } + ); + } + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } +}; + +/** + * Check upload status (useful with webhooks) + */ +export async function checkImageStatus( + imageId: string, + env: Env +): Promise<{ + success: boolean; + result?: { + id: string; + uploaded: string; + draft?: boolean; // true if upload not completed yet + variants?: string[]; + }; +}> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/${imageId}`, + { + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + } + } + ); + + return response.json(); +} + +/** + * Usage example: + * + * Frontend calls this endpoint: + * ```javascript + * const response = await fetch('/api/upload-url', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ userId: '12345' }) + * }); + * const { uploadURL, imageId } = await response.json(); + * + * // Now frontend can upload directly to uploadURL + * const formData = new FormData(); + * formData.append('file', fileInput.files[0]); // MUST be named 'file' + * + * await fetch(uploadURL, { + * method: 'POST', + * body: formData // NO Content-Type header + * }); + * ``` + * + * Custom expiry: + * ```typescript + * const result = await generateUploadURL({ + * expiry: new Date('2025-10-26T18:00:00Z').toISOString(), // Specific time + * metadata: { purpose: 'profile-photo' } + * }, env); + * ``` + */ diff --git a/templates/direct-creator-upload-frontend.html b/templates/direct-creator-upload-frontend.html new file mode 100644 index 0000000..683863c --- /dev/null +++ b/templates/direct-creator-upload-frontend.html @@ -0,0 +1,294 @@ + + + + + + Direct Creator Upload - Cloudflare Images + + + +

Upload Image to Cloudflare

+ +
+ + +
+ + Preview + + + +
+
0%
+
+ +
+
+ + + + + + diff --git a/templates/package.json b/templates/package.json new file mode 100644 index 0000000..fdb88f6 --- /dev/null +++ b/templates/package.json @@ -0,0 +1,16 @@ +{ + "name": "cloudflare-images-example", + "version": "1.0.0", + "description": "Cloudflare Images examples and templates", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "tail": "wrangler tail" + }, + "dependencies": {}, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241112.0", + "typescript": "^5.9.0", + "wrangler": "^3.95.0" + } +} diff --git a/templates/responsive-images-srcset.html b/templates/responsive-images-srcset.html new file mode 100644 index 0000000..b3c75cd --- /dev/null +++ b/templates/responsive-images-srcset.html @@ -0,0 +1,251 @@ + + + + + + Responsive Images with Cloudflare Images + + + +

Responsive Images with Cloudflare Images

+ + +
+

1. Using Named Variants

+

Serve different image sizes based on viewport width using predefined variants.

+ + Responsive image with named variants + +
+ +<img + srcset=" + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile 480w, + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/tablet 768w, + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop 1920w + " + sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px" + src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop" + alt="Responsive image" + loading="lazy" +/> + +
+
+ + +
+

2. Using Flexible Variants

+

Dynamic transformations with format=auto for optimal WebP/AVIF delivery.

+ + Responsive image with flexible variants + +
+ +<img + srcset=" + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=480,f=auto 480w, + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=768,f=auto 768w, + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto 1920w + " + sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px" + src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto" +/> + +
+
+ + +
+

3. Art Direction (Different Crops)

+

Serve different image crops for mobile vs desktop (e.g., portrait on mobile, landscape on desktop).

+ + + + + Art directed image + + +
+ +<picture> + <source + media="(max-width: 767px)" + srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square" + /> + <source + media="(min-width: 768px)" + srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide" + /> + <img + src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide" + alt="Art directed image" + /> +</picture> + +
+
+ + +
+

4. High-DPI (Retina) Displays

+

Serve 2x images for high-resolution screens.

+ + Retina-ready image + +
+ +<img + srcset=" + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=1,f=auto 1x, + https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,dpr=2,f=auto 2x + " + src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=400,f=auto" +/> + +
+
+ + +
+

5. Low-Quality Image Placeholder (LQIP)

+

Load a tiny blurred placeholder first, then swap to full image.

+ + Image with LQIP + + + +
+ +<img + src="https://imagedelivery.net/HASH/ID/w=50,q=10,blur=20,f=webp" + data-src="https://imagedelivery.net/HASH/ID/w=1920,f=auto" + alt="Image with LQIP" +/> + +<script> + const img = document.querySelector('img'); + const fullImg = new Image(); + fullImg.src = img.getAttribute('data-src'); + fullImg.onload = () => { img.src = fullImg.src; }; +</script> + +
+
+ + +
+

6. Using URL Transformations (/cdn-cgi/image/)

+

Transform ANY publicly accessible image (not just Cloudflare Images storage).

+ + Transformed image from origin + +
+ +<img + srcset=" + /cdn-cgi/image/width=480,format=auto/uploads/photo.jpg 480w, + /cdn-cgi/image/width=768,format=auto/uploads/photo.jpg 768w, + /cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg 1920w + " + sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px" +/> + +
+
+ + +
+

Best Practices

+
    +
  • Always use format=auto for optimal WebP/AVIF delivery
  • +
  • Add loading="lazy" for images below the fold
  • +
  • Use sizes attribute to match your CSS layout
  • +
  • Provide descriptive alt text for accessibility
  • +
  • Consider art direction for different screen sizes (portrait vs landscape)
  • +
  • Use LQIP (Low-Quality Image Placeholder) for better perceived performance
  • +
  • Named variants: Best for consistent sizes and signed URLs
  • +
  • Flexible variants: Best for dynamic sizing (public images only)
  • +
+
+ + diff --git a/templates/signed-urls-generation.ts b/templates/signed-urls-generation.ts new file mode 100644 index 0000000..60ab803 --- /dev/null +++ b/templates/signed-urls-generation.ts @@ -0,0 +1,255 @@ +/** + * Cloudflare Images - Signed URLs Generation + * + * Generate time-limited, signed URLs for private images using HMAC-SHA256. + * + * URL format: + * https://imagedelivery.net///?exp=&sig= + */ + +interface Env { + IMAGES_ACCOUNT_HASH: string; + IMAGES_SIGNING_KEY: string; // From Dashboard → Images → Keys +} + +/** + * Generate signed URL for private image + */ +export async function generateSignedURL( + imageId: string, + variant: string, + expirySeconds: number = 3600, // Default: 1 hour + env: Env +): Promise { + // Calculate expiry timestamp + const now = Math.floor(Date.now() / 1000); + const expiry = now + expirySeconds; + + // String to sign: {imageId}{variant}{expiry} + const stringToSign = `${imageId}${variant}${expiry}`; + + // Generate HMAC-SHA256 signature + const encoder = new TextEncoder(); + const keyData = encoder.encode(env.IMAGES_SIGNING_KEY); + const messageData = encoder.encode(stringToSign); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); + + // Convert to hex string + const sig = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + // Build signed URL + return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`; +} + +/** + * Generate signed URL with absolute expiry time + */ +export async function generateSignedURLWithExpiry( + imageId: string, + variant: string, + expiryDate: Date, + env: Env +): Promise { + const expiry = Math.floor(expiryDate.getTime() / 1000); + const stringToSign = `${imageId}${variant}${expiry}`; + + const encoder = new TextEncoder(); + const keyData = encoder.encode(env.IMAGES_SIGNING_KEY); + const messageData = encoder.encode(stringToSign); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); + const sig = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/${variant}?exp=${expiry}&sig=${sig}`; +} + +/** + * Generate signed URLs for multiple variants + */ +export async function generateSignedURLsForVariants( + imageId: string, + variants: string[], + expirySeconds: number, + env: Env +): Promise> { + const urls: Record = {}; + + for (const variant of variants) { + urls[variant] = await generateSignedURL(imageId, variant, expirySeconds, env); + } + + return urls; +} + +/** + * Example Cloudflare Worker + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Generate signed URL: GET /api/signed-url/:imageId/:variant + if (request.method === 'GET' && url.pathname.startsWith('/api/signed-url/')) { + const parts = url.pathname.replace('/api/signed-url/', '').split('/'); + const [imageId, variant] = parts; + + if (!imageId || !variant) { + return Response.json({ error: 'Missing imageId or variant' }, { status: 400 }); + } + + // Parse expiry (default 1 hour) + const expirySeconds = parseInt(url.searchParams.get('expiry') || '3600'); + + try { + const signedURL = await generateSignedURL(imageId, variant, expirySeconds, env); + + return Response.json({ + signedURL, + expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString() + }); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to generate signed URL' }, + { status: 500 } + ); + } + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } +}; + +/** + * Common expiry presets + */ +export const expiryPresets = { + fiveMinutes: 5 * 60, + fifteenMinutes: 15 * 60, + oneHour: 60 * 60, + oneDay: 24 * 60 * 60, + oneWeek: 7 * 24 * 60 * 60 +}; + +/** + * Generate signed URL with preset expiry + */ +export async function generateSignedURLPreset( + imageId: string, + variant: string, + preset: keyof typeof expiryPresets, + env: Env +): Promise { + return generateSignedURL(imageId, variant, expiryPresets[preset], env); +} + +/** + * Verify if URL signature is valid (for reference, Cloudflare handles verification) + */ +export async function verifySignature( + imageId: string, + variant: string, + expiry: number, + signature: string, + env: Env +): Promise { + // Check if expired + const now = Math.floor(Date.now() / 1000); + if (expiry < now) { + return false; + } + + // Generate expected signature + const stringToSign = `${imageId}${variant}${expiry}`; + const encoder = new TextEncoder(); + const keyData = encoder.encode(env.IMAGES_SIGNING_KEY); + const messageData = encoder.encode(stringToSign); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const expectedSignature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); + const expectedSig = Array.from(new Uint8Array(expectedSignature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return expectedSig === signature; +} + +/** + * Usage examples: + * + * ```typescript + * // Generate signed URL valid for 1 hour + * const signedURL = await generateSignedURL( + * 'image-id', + * 'public', + * 3600, + * env + * ); + * // https://imagedelivery.net/{hash}/{id}/public?exp=1234567890&sig=abc123... + * + * // Generate with specific expiry date + * const expiryDate = new Date('2025-10-27T18:00:00Z'); + * const signedURL = await generateSignedURLWithExpiry( + * 'image-id', + * 'public', + * expiryDate, + * env + * ); + * + * // Generate for multiple variants + * const urls = await generateSignedURLsForVariants( + * 'image-id', + * ['thumbnail', 'medium', 'large'], + * 3600, + * env + * ); + * // { thumbnail: 'https://...', medium: 'https://...', large: 'https://...' } + * + * // Use preset expiry + * const signedURL = await generateSignedURLPreset( + * 'image-id', + * 'public', + * 'oneDay', + * env + * ); + * ``` + * + * REQUIREMENTS: + * - Image must be uploaded with requireSignedURLs=true + * - Get signing key from Dashboard → Images → Keys + * - CANNOT use flexible variants with signed URLs (use named variants only) + * + * WHEN TO USE: + * - User profile photos (private until shared) + * - Paid content (time-limited access) + * - Temporary downloads + * - Secure image delivery + */ diff --git a/templates/transform-via-url.ts b/templates/transform-via-url.ts new file mode 100644 index 0000000..ca210cb --- /dev/null +++ b/templates/transform-via-url.ts @@ -0,0 +1,225 @@ +/** + * Cloudflare Images - Transform via URL + * + * Transform images using the special URL format: + * /cdn-cgi/image// + * + * Works on ANY publicly accessible image (not just Cloudflare Images storage). + */ + +/** + * URL Transformation Examples + */ + +// Basic resize +const thumbnailURL = '/cdn-cgi/image/width=300,height=300,fit=cover/uploads/photo.jpg'; + +// Responsive with auto format (WebP/AVIF) +const responsiveURL = '/cdn-cgi/image/width=800,quality=85,format=auto/uploads/hero.jpg'; + +// Smart crop to face +const avatarURL = '/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg'; + +// Blur effect +const blurredURL = '/cdn-cgi/image/blur=20,quality=50/uploads/background.jpg'; + +// Sharpen +const sharpenedURL = '/cdn-cgi/image/sharpen=3,quality=90/uploads/product.jpg'; + +// Rotate and flip +const rotatedURL = '/cdn-cgi/image/rotate=90,flip=h/uploads/document.jpg'; + +/** + * All available options (comma-separated) + */ +interface TransformOptions { + // Sizing + width?: number; // Max width in pixels (alias: w) + height?: number; // Max height in pixels (alias: h) + dpr?: number; // Device pixel ratio (1-3) + + // Fit modes + fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad'; + + // Quality + quality?: number; // 1-100 (alias: q) + compression?: 'fast' | 'lossless'; // WebP only + + // Format + format?: 'auto' | 'avif' | 'webp' | 'jpeg' | 'baseline-jpeg' | 'json'; + // 'auto' serves AVIF → WebP → original based on browser support + + // Cropping + gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string; // Or 'XxY' coordinates + zoom?: number; // 0-1 for face cropping + trim?: number; // Remove border (pixels) + + // Effects + blur?: number; // 1-250 + sharpen?: number; // 0-10 + brightness?: number; // 0-2 (1 = no change) + contrast?: number; // 0-2 (1 = no change) + gamma?: number; // 0-2 (1 = no change) + + // Rotation + rotate?: 0 | 90 | 180 | 270; + flip?: 'h' | 'v' | 'hv'; // Horizontal, vertical, both + + // Other + background?: string; // CSS color for transparency/padding + metadata?: 'none' | 'copyright' | 'keep'; // EXIF handling + anim?: boolean; // Preserve GIF/WebP animation (default: true) +} + +/** + * Build transformation URL + */ +export function buildTransformURL( + imagePath: string, + options: Partial +): string { + const params: string[] = []; + + // Sizing + if (options.width) params.push(`width=${options.width}`); + if (options.height) params.push(`height=${options.height}`); + if (options.dpr) params.push(`dpr=${options.dpr}`); + + // Fit + if (options.fit) params.push(`fit=${options.fit}`); + + // Quality + if (options.quality) params.push(`quality=${options.quality}`); + if (options.compression) params.push(`compression=${options.compression}`); + + // Format + if (options.format) params.push(`format=${options.format}`); + + // Cropping + if (options.gravity) params.push(`gravity=${options.gravity}`); + if (options.zoom) params.push(`zoom=${options.zoom}`); + if (options.trim) params.push(`trim=${options.trim}`); + + // Effects + if (options.blur) params.push(`blur=${options.blur}`); + if (options.sharpen) params.push(`sharpen=${options.sharpen}`); + if (options.brightness) params.push(`brightness=${options.brightness}`); + if (options.contrast) params.push(`contrast=${options.contrast}`); + if (options.gamma) params.push(`gamma=${options.gamma}`); + + // Rotation + if (options.rotate) params.push(`rotate=${options.rotate}`); + if (options.flip) params.push(`flip=${options.flip}`); + + // Other + if (options.background) params.push(`background=${encodeURIComponent(options.background)}`); + if (options.metadata) params.push(`metadata=${options.metadata}`); + if (options.anim === false) params.push('anim=false'); + + return `/cdn-cgi/image/${params.join(',')}/${imagePath}`; +} + +/** + * Example HTML generation + */ +export function generateResponsiveHTML(imagePath: string, alt: string): string { + return ` +${alt} + `.trim(); +} + +/** + * Common presets + */ +export const presets = { + thumbnail: (path: string) => buildTransformURL(path, { + width: 300, + height: 300, + fit: 'cover', + quality: 85, + format: 'auto' + }), + + avatar: (path: string) => buildTransformURL(path, { + width: 200, + height: 200, + fit: 'cover', + gravity: 'face', + quality: 90, + format: 'auto' + }), + + hero: (path: string) => buildTransformURL(path, { + width: 1920, + height: 1080, + fit: 'cover', + quality: 85, + format: 'auto' + }), + + blurPlaceholder: (path: string) => buildTransformURL(path, { + width: 50, + quality: 10, + blur: 20, + format: 'webp' + }), + + productImage: (path: string) => buildTransformURL(path, { + width: 800, + height: 800, + fit: 'contain', + quality: 90, + sharpen: 2, + format: 'auto' + }) +}; + +/** + * Usage examples: + * + * ```html + * + * + * + * + * + * + * + * + * + * + * + * ``` + * + * With helper functions: + * ```typescript + * const url = buildTransformURL('uploads/photo.jpg', { + * width: 800, + * quality: 85, + * format: 'auto' + * }); + * + * const html = generateResponsiveHTML('uploads/hero.jpg', 'Hero image'); + * + * const thumbURL = presets.thumbnail('uploads/photo.jpg'); + * ``` + * + * IMPORTANT: + * - Must enable transformations on zone first (Dashboard → Images → Transformations) + * - Works on any publicly accessible image (not just Cloudflare Images storage) + * - Source image must use HTTPS (HTTP not supported) + * - URL-encode special characters in paths + */ diff --git a/templates/transform-via-workers.ts b/templates/transform-via-workers.ts new file mode 100644 index 0000000..5f74739 --- /dev/null +++ b/templates/transform-via-workers.ts @@ -0,0 +1,331 @@ +/** + * Cloudflare Images - Transform via Workers + * + * Use Workers to apply transformations programmatically with fetch() cf.image options. + * + * Benefits: + * - Custom URL schemes (hide storage location) + * - Preset names instead of pixel values + * - Content negotiation (serve optimal format) + * - Access control before serving + */ + +interface Env { + // Optional: If storing originals in R2 + IMAGES_BUCKET?: R2Bucket; +} + +interface ImageTransformOptions { + width?: number; + height?: number; + fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad'; + quality?: number; // 1-100 + format?: 'avif' | 'webp' | 'jpeg' | 'auto'; + gravity?: 'auto' | 'face' | 'left' | 'right' | 'top' | 'bottom' | string; + blur?: number; // 1-250 + sharpen?: number; // 0-10 + rotate?: 0 | 90 | 180 | 270; + flip?: 'h' | 'v' | 'hv'; + anim?: boolean; + metadata?: 'none' | 'copyright' | 'keep'; + background?: string; +} + +/** + * Example 1: Custom URL schemes with preset names + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Custom URL: /images/thumbnail/photo.jpg + if (url.pathname.startsWith('/images/thumbnail/')) { + const imagePath = url.pathname.replace('/images/thumbnail/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + return fetch(imageURL, { + cf: { + image: { + width: 300, + height: 300, + fit: 'cover', + quality: 85, + format: 'auto' + } + } + }); + } + + // Custom URL: /images/avatar/photo.jpg + if (url.pathname.startsWith('/images/avatar/')) { + const imagePath = url.pathname.replace('/images/avatar/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + return fetch(imageURL, { + cf: { + image: { + width: 200, + height: 200, + fit: 'cover', + gravity: 'face', // Smart crop to face + quality: 90, + format: 'auto' + } + } + }); + } + + // Custom URL: /images/large/photo.jpg + if (url.pathname.startsWith('/images/large/')) { + const imagePath = url.pathname.replace('/images/large/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + return fetch(imageURL, { + cf: { + image: { + width: 1920, + quality: 85, + format: 'auto' + } + } + }); + } + + return new Response('Not found', { status: 404 }); + } +}; + +/** + * Example 2: Content negotiation (serve optimal format) + */ +function getOptimalFormat(request: Request): 'avif' | 'webp' | 'auto' { + const accept = request.headers.get('accept') || ''; + + if (/image\/avif/.test(accept)) { + return 'avif'; + } else if (/image\/webp/.test(accept)) { + return 'webp'; + } + + return 'auto'; // Cloudflare decides +} + +export const contentNegotiationWorker = { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const imagePath = url.pathname.replace('/images/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + return fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: getOptimalFormat(request) + } + } + }); + } +}; + +/** + * Example 3: Dynamic sizing based on query params + */ +export const dynamicSizeWorker = { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const imagePath = url.pathname.replace('/images/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + // Parse query params: /images/photo.jpg?w=800&q=85 + const width = parseInt(url.searchParams.get('w') || '1920'); + const quality = parseInt(url.searchParams.get('q') || '85'); + + // Validate + const safeWidth = Math.min(Math.max(width, 100), 4000); // 100-4000px + const safeQuality = Math.min(Math.max(quality, 10), 100); // 10-100 + + return fetch(imageURL, { + cf: { + image: { + width: safeWidth, + quality: safeQuality, + format: 'auto' + } + } + }); + } +}; + +/** + * Example 4: Access control before serving + */ +export const protectedImageWorker = { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Check authentication (example) + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response('Unauthorized', { status: 401 }); + } + + // Verify token (simplified example) + const token = authHeader.replace('Bearer ', ''); + if (token !== 'valid-token') { + return new Response('Forbidden', { status: 403 }); + } + + // Serve image after auth check + const imagePath = url.pathname.replace('/protected/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + return fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } + }); + } +}; + +/** + * Example 5: R2 integration + */ +export const r2ImageWorker = { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const key = url.pathname.replace('/images/', ''); + + // Get image from R2 + const object = await env.IMAGES_BUCKET?.get(key); + if (!object) { + return new Response('Image not found', { status: 404 }); + } + + // Transform and serve + return fetch(new Request(url.toString(), { + method: 'GET', + body: object.body + }), { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } + }); + } +}; + +/** + * Example 6: Prevent transformation loops (error 9403) + */ +export const safeTransformWorker = { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // ✅ CORRECT: Fetch external origin + if (url.pathname.startsWith('/images/')) { + const imagePath = url.pathname.replace('/images/', ''); + const originURL = `https://storage.example.com/${imagePath}`; + + return fetch(originURL, { + cf: { + image: { + width: 800, + quality: 85 + } + } + }); + } + + // ❌ WRONG: Don't fetch Worker's own URL (causes loop) + // return fetch(request, { cf: { image: { width: 800 } } }); // ERROR 9403 + + return new Response('Not found', { status: 404 }); + } +}; + +/** + * Example 7: Error handling + */ +export const robustImageWorker = { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const imagePath = url.pathname.replace('/images/', ''); + const imageURL = `https://storage.example.com/${imagePath}`; + + try { + // Verify origin returns image (prevent error 9412) + const headResponse = await fetch(imageURL, { method: 'HEAD' }); + const contentType = headResponse.headers.get('content-type'); + + if (!contentType?.startsWith('image/')) { + return new Response('Not an image', { status: 400 }); + } + + // Transform + const response = await fetch(imageURL, { + cf: { + image: { + width: 800, + quality: 85, + format: 'auto' + } + } + }); + + // Check for transformation errors + const cfResized = response.headers.get('Cf-Resized'); + if (cfResized?.includes('err=')) { + console.error('Transformation error:', cfResized); + return new Response('Image transformation failed', { status: 502 }); + } + + return response; + + } catch (error) { + console.error('Image fetch error:', error); + return new Response('Failed to fetch image', { status: 502 }); + } + } +}; + +/** + * Helper: Build transform options + */ +export function buildTransformOptions( + preset: 'thumbnail' | 'avatar' | 'hero' | 'product', + overrides?: Partial +): ImageTransformOptions { + const presets = { + thumbnail: { width: 300, height: 300, fit: 'cover' as const, quality: 85 }, + avatar: { width: 200, height: 200, fit: 'cover' as const, gravity: 'face', quality: 90 }, + hero: { width: 1920, height: 1080, fit: 'cover' as const, quality: 85 }, + product: { width: 800, height: 800, fit: 'contain' as const, quality: 90, sharpen: 2 } + }; + + return { + ...presets[preset], + format: 'auto', + ...overrides + }; +} + +/** + * CRITICAL ERROR CODES: + * + * - 9401: Invalid cf.image options + * - 9402: Image too large or connection interrupted + * - 9403: Request loop (Worker fetching itself) + * - 9406/9419: Non-HTTPS URL or URL has spaces/unescaped Unicode + * - 9412: Origin returned non-image (e.g., HTML error page) + * - 9413: Image exceeds 100 megapixels + * + * Check 'Cf-Resized' header for error codes. + */ diff --git a/templates/upload-api-basic.ts b/templates/upload-api-basic.ts new file mode 100644 index 0000000..0b80391 --- /dev/null +++ b/templates/upload-api-basic.ts @@ -0,0 +1,150 @@ +/** + * Cloudflare Images - Basic Upload via API + * + * Uploads an image file to Cloudflare Images storage. + * + * Usage: + * const result = await uploadImageToCloudflare(file, { + * requireSignedURLs: false, + * metadata: { userId: '12345' } + * }); + */ + +interface Env { + IMAGES_ACCOUNT_ID: string; + IMAGES_API_TOKEN: string; +} + +interface UploadOptions { + id?: string; // Custom ID (optional, auto-generated if not provided) + requireSignedURLs?: boolean; // true for private images + metadata?: Record; // Max 1024 bytes, not visible to end users +} + +interface CloudflareImagesResponse { + success: boolean; + result?: { + id: string; + filename: string; + uploaded: string; + requireSignedURLs: boolean; + variants: string[]; + }; + errors?: Array<{ code: number; message: string }>; +} + +/** + * Upload image to Cloudflare Images + */ +export async function uploadImageToCloudflare( + file: File, + options: UploadOptions = {}, + env: Env +): Promise { + const formData = new FormData(); + + // Required: File to upload + formData.append('file', file); + + // Optional: Custom ID (if not provided, auto-generated) + if (options.id) { + formData.append('id', options.id); + } + + // Optional: Require signed URLs for private images + if (options.requireSignedURLs !== undefined) { + formData.append('requireSignedURLs', String(options.requireSignedURLs)); + } + + // Optional: Metadata (JSON object, max 1024 bytes) + if (options.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + // Upload to Cloudflare Images API + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + // Don't set Content-Type - FormData sets it automatically with boundary + }, + body: formData + } + ); + + const result: CloudflareImagesResponse = await response.json(); + + if (!result.success) { + console.error('Upload failed:', result.errors); + throw new Error(`Upload failed: ${result.errors?.[0]?.message || 'Unknown error'}`); + } + + return result; +} + +/** + * Example Cloudflare Worker endpoint + */ +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method === 'POST' && new URL(request.url).pathname === '/upload') { + try { + // Parse multipart/form-data from request + const formData = await request.formData(); + const file = formData.get('image') as File; + + if (!file) { + return Response.json({ error: 'No file provided' }, { status: 400 }); + } + + // Upload to Cloudflare Images + const result = await uploadImageToCloudflare( + file, + { + requireSignedURLs: false, + metadata: { + uploadedBy: 'worker', + timestamp: new Date().toISOString() + } + }, + env + ); + + return Response.json({ + success: true, + imageId: result.result?.id, + variants: result.result?.variants + }); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Upload failed' }, + { status: 500 } + ); + } + } + + return Response.json({ error: 'Method not allowed' }, { status: 405 }); + } +}; + +/** + * Example usage from another script: + * + * ```typescript + * const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + * const file = fileInput.files?.[0]; + * + * if (file) { + * const result = await uploadImageToCloudflare(file, { + * requireSignedURLs: false, + * metadata: { source: 'user-upload' } + * }, env); + * + * console.log('Uploaded:', result.result?.id); + * console.log('Serve at:', result.result?.variants[0]); + * } + * ``` + */ diff --git a/templates/upload-via-url.ts b/templates/upload-via-url.ts new file mode 100644 index 0000000..145a380 --- /dev/null +++ b/templates/upload-via-url.ts @@ -0,0 +1,207 @@ +/** + * Cloudflare Images - Upload via URL + * + * Ingest images from external URLs without downloading first. + * + * Use cases: + * - Migrating images from another service + * - Ingesting user-provided URLs + * - Backing up images from external sources + */ + +interface Env { + IMAGES_ACCOUNT_ID: string; + IMAGES_API_TOKEN: string; +} + +interface UploadViaURLOptions { + url: string; // Image URL to ingest + id?: string; // Custom ID (optional) + requireSignedURLs?: boolean; + metadata?: Record; +} + +interface CloudflareImagesResponse { + success: boolean; + result?: { + id: string; + filename: string; + uploaded: string; + requireSignedURLs: boolean; + variants: string[]; + }; + errors?: Array<{ code: number; message: string }>; +} + +/** + * Upload image from external URL + */ +export async function uploadImageViaURL( + options: UploadViaURLOptions, + env: Env +): Promise { + const formData = new FormData(); + + // Required: URL to ingest + formData.append('url', options.url); + + // Optional: Custom ID + if (options.id) { + formData.append('id', options.id); + } + + // Optional: Require signed URLs + if (options.requireSignedURLs !== undefined) { + formData.append('requireSignedURLs', String(options.requireSignedURLs)); + } + + // Optional: Metadata + if (options.metadata) { + formData.append('metadata', JSON.stringify(options.metadata)); + } + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + }, + body: formData + } + ); + + const result: CloudflareImagesResponse = await response.json(); + + if (!result.success) { + console.error('Upload via URL failed:', result.errors); + throw new Error(`Upload via URL failed: ${result.errors?.[0]?.message || 'Unknown error'}`); + } + + return result; +} + +/** + * Example Cloudflare Worker + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Endpoint: POST /ingest-image + if (request.method === 'POST' && url.pathname === '/ingest-image') { + try { + const body = await request.json<{ imageUrl: string }>(); + + if (!body.imageUrl) { + return Response.json({ error: 'imageUrl required' }, { status: 400 }); + } + + // Validate URL format + try { + new URL(body.imageUrl); + } catch { + return Response.json({ error: 'Invalid URL' }, { status: 400 }); + } + + // Upload from external URL + const result = await uploadImageViaURL( + { + url: body.imageUrl, + metadata: { + source: 'external', + ingestedAt: new Date().toISOString() + } + }, + env + ); + + return Response.json({ + success: true, + imageId: result.result?.id, + variants: result.result?.variants + }); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Ingestion failed' }, + { status: 500 } + ); + } + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } +}; + +/** + * Batch ingestion example + */ +export async function batchIngestImages( + imageUrls: string[], + env: Env +): Promise> { + const results = await Promise.allSettled( + imageUrls.map(async (url) => { + return { + url, + result: await uploadImageViaURL({ url }, env) + }; + }) + ); + + return results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + url: imageUrls[index], + error: result.reason instanceof Error ? result.reason.message : 'Unknown error' + }; + } + }); +} + +/** + * Example with authentication for private origins + */ +export async function uploadFromPrivateURL( + imageUrl: string, + username: string, + password: string, + env: Env +): Promise { + // Cloudflare supports HTTP Basic Auth in URL + const urlObj = new URL(imageUrl); + const authenticatedURL = `${urlObj.protocol}//${username}:${password}@${urlObj.host}${urlObj.pathname}${urlObj.search}`; + + return uploadImageViaURL({ url: authenticatedURL }, env); +} + +/** + * Usage examples: + * + * ```typescript + * // Single image + * const result = await uploadImageViaURL({ + * url: 'https://example.com/photo.jpg', + * metadata: { source: 'migration' } + * }, env); + * + * // Batch ingestion + * const urls = [ + * 'https://example.com/photo1.jpg', + * 'https://example.com/photo2.jpg', + * 'https://example.com/photo3.jpg' + * ]; + * const results = await batchIngestImages(urls, env); + * + * // Private origin with auth + * const result = await uploadFromPrivateURL( + * 'https://private-storage.example.com/image.jpg', + * 'username', + * 'password', + * env + * ); + * ``` + */ diff --git a/templates/variants-management.ts b/templates/variants-management.ts new file mode 100644 index 0000000..c0120be --- /dev/null +++ b/templates/variants-management.ts @@ -0,0 +1,312 @@ +/** + * Cloudflare Images - Variants Management + * + * Create, list, update, and delete image variants. + * Variants define predefined transformations for different use cases. + */ + +interface Env { + IMAGES_ACCOUNT_ID: string; + IMAGES_API_TOKEN: string; +} + +interface VariantOptions { + fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad'; + width?: number; + height?: number; + metadata?: 'none' | 'copyright' | 'keep'; +} + +interface Variant { + id: string; + options: VariantOptions; + neverRequireSignedURLs?: boolean; +} + +/** + * Create a new variant + */ +export async function createVariant( + id: string, + options: VariantOptions, + neverRequireSignedURLs: boolean = false, + env: Env +): Promise<{ success: boolean; result?: Variant }> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id, + options, + neverRequireSignedURLs + }) + } + ); + + return response.json(); +} + +/** + * List all variants + */ +export async function listVariants( + env: Env +): Promise<{ success: boolean; result?: { variants: Variant[] } }> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants`, + { + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + } + } + ); + + return response.json(); +} + +/** + * Get a specific variant + */ +export async function getVariant( + id: string, + env: Env +): Promise<{ success: boolean; result?: Variant }> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`, + { + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + } + } + ); + + return response.json(); +} + +/** + * Update a variant + */ +export async function updateVariant( + id: string, + options: VariantOptions, + neverRequireSignedURLs?: boolean, + env: Env +): Promise<{ success: boolean; result?: Variant }> { + const body: Record = { options }; + if (neverRequireSignedURLs !== undefined) { + body.neverRequireSignedURLs = neverRequireSignedURLs; + } + + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + } + ); + + return response.json(); +} + +/** + * Delete a variant + */ +export async function deleteVariant( + id: string, + env: Env +): Promise<{ success: boolean }> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/variants/${id}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}` + } + } + ); + + return response.json(); +} + +/** + * Enable flexible variants (dynamic transformations) + */ +export async function enableFlexibleVariants( + enabled: boolean, + env: Env +): Promise<{ success: boolean }> { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.IMAGES_ACCOUNT_ID}/images/v1/config`, + { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${env.IMAGES_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + flexible_variants: enabled + }) + } + ); + + return response.json(); +} + +/** + * Example Worker endpoint + */ +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + // Create variant: POST /api/variants + if (request.method === 'POST' && url.pathname === '/api/variants') { + try { + const body = await request.json<{ + id: string; + width?: number; + height?: number; + fit?: string; + }>(); + + const result = await createVariant( + body.id, + { + width: body.width, + height: body.height, + fit: body.fit as VariantOptions['fit'], + metadata: 'none' + }, + false, + env + ); + + return Response.json(result); + + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : 'Failed to create variant' }, + { status: 500 } + ); + } + } + + // List variants: GET /api/variants + if (request.method === 'GET' && url.pathname === '/api/variants') { + const result = await listVariants(env); + return Response.json(result); + } + + return Response.json({ error: 'Not found' }, { status: 404 }); + } +}; + +/** + * Common variant presets + */ +export async function setupCommonVariants(env: Env): Promise { + // Thumbnail + await createVariant('thumbnail', { + width: 300, + height: 300, + fit: 'cover', + metadata: 'none' + }, false, env); + + // Avatar + await createVariant('avatar', { + width: 200, + height: 200, + fit: 'cover', + metadata: 'none' + }, false, env); + + // Small + await createVariant('small', { + width: 480, + fit: 'scale-down', + metadata: 'none' + }, false, env); + + // Medium + await createVariant('medium', { + width: 768, + fit: 'scale-down', + metadata: 'none' + }, false, env); + + // Large + await createVariant('large', { + width: 1920, + fit: 'scale-down', + metadata: 'none' + }, false, env); + + // Hero (wide) + await createVariant('hero', { + width: 1920, + height: 1080, + fit: 'cover', + metadata: 'none' + }, false, env); + + // Product (square) + await createVariant('product', { + width: 800, + height: 800, + fit: 'contain', + metadata: 'none' + }, false, env); +} + +/** + * Usage examples: + * + * ```typescript + * // Create a variant + * await createVariant('thumbnail', { + * width: 300, + * height: 300, + * fit: 'cover', + * metadata: 'none' + * }, false, env); + * + * // List all variants + * const { result } = await listVariants(env); + * console.log(result?.variants); + * + * // Update a variant + * await updateVariant('thumbnail', { + * width: 350, // Changed from 300 + * height: 350, + * fit: 'cover' + * }, undefined, env); + * + * // Delete a variant + * await deleteVariant('old-variant', env); + * + * // Enable flexible variants (dynamic transformations) + * await enableFlexibleVariants(true, env); + * // Now can use: /w=400,sharpen=3 in URLs + * + * // Use variant in image URL + * const imageURL = `https://imagedelivery.net/${accountHash}/${imageId}/thumbnail`; + * ``` + * + * LIMITS: + * - Maximum 100 named variants per account + * - Flexible variants: unlimited dynamic transformations (but can't use with signed URLs) + * + * WHEN TO USE: + * - Named variants: Consistent sizes, private images (signed URLs), predictable URLs + * - Flexible variants: Dynamic sizing, public images only, rapid prototyping + */ diff --git a/templates/wrangler-images-binding.jsonc b/templates/wrangler-images-binding.jsonc new file mode 100644 index 0000000..03e38fa --- /dev/null +++ b/templates/wrangler-images-binding.jsonc @@ -0,0 +1,41 @@ +{ + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2025-10-26", + + // Cloudflare Images doesn't require explicit bindings in wrangler.jsonc + // Images API is accessed via fetch to api.cloudflare.com + // Image Transformations work automatically when enabled on your zone + + // Example: If storing originals in R2 and using Images for transformations + "r2_buckets": [ + { + "binding": "IMAGES_BUCKET", + "bucket_name": "original-images", + "preview_bucket_name": "original-images-preview" + } + ], + + // Environment variables for Images API + "vars": { + "IMAGES_ACCOUNT_ID": "your-account-id", + "IMAGES_ACCOUNT_HASH": "your-account-hash" // From Dashboard → Images → Developer Resources + }, + + // Secrets (set via: wrangler secret put IMAGES_API_TOKEN) + // IMAGES_API_TOKEN - API token with Cloudflare Images: Edit permission + // IMAGES_SIGNING_KEY - Key for signed URLs (optional, from Dashboard → Images → Keys) + + // No explicit binding needed for: + // - Image Transformations (/cdn-cgi/image/...) + // - Workers fetch with cf.image options + // - Direct Creator Upload API + + // Example worker routes + "routes": [ + { + "pattern": "example.com/*", + "zone_name": "example.com" + } + ] +}