Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:18 +08:00
commit b0e1f6dfe3
25 changed files with 6267 additions and 0 deletions

View File

@@ -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": [
"./"
]
}

3
README.md Normal file
View File

@@ -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.

563
SKILL.md Normal file
View File

@@ -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/<ACCOUNT_ID>/images/v1 \
-H 'Authorization: Bearer <API_TOKEN>' \
-H 'Content-Type: multipart/form-data' \
-F 'file=@./image.jpg'
```
**3. Serve**: `https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/public`
**4. Transform** (optional): Dashboard → Images → Transformations → Enable for zone
```html
<img src="/cdn-cgi/image/width=800,quality=85/uploads/photo.jpg" />
```
---
## 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/<OPTIONS>/<SOURCE>`
- 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/<HASH>/<ID>/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=<TIMESTAMP>&sig=<HMAC>`).
**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/<HASH>/<SVG_ID>/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/<HASH>/<ID>/<VARIANT>` (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

129
plugin.lock.json Normal file
View File

@@ -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": []
}
}

403
references/api-reference.md Normal file
View File

@@ -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 <API_TOKEN>
```
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 <API_TOKEN>`
- `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 <API_TOKEN>" \
--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 <API_TOKEN>" \
--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 <API_TOKEN>`
- `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 <API_TOKEN>"
```
**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 <API_TOKEN>"
```
**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 <API_TOKEN>"
```
**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 <API_TOKEN>" \
--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 <API_TOKEN>"
```
---
### 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 <BATCH_TOKEN>" \
--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/

View File

@@ -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<Response> {
// 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
<form id="upload-form">
<input type="file" id="file-input" accept="image/*" />
<button type="submit">Upload</button>
<div id="status"></div>
</form>
<script>
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('file-input');
const status = document.getElementById('status');
const file = fileInput.files[0];
if (!file) {
status.textContent = 'Please select a file';
return;
}
try {
status.textContent = 'Requesting upload URL...';
// Get upload URL from backend
const urlResponse = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: 'user-123' })
});
const { uploadURL, imageId } = await urlResponse.json();
status.textContent = 'Uploading...';
// Upload directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadURL, {
method: 'POST',
body: formData
});
if (uploadResponse.ok) {
status.textContent = `✓ Upload successful! Image ID: ${imageId}`;
} else {
throw new Error('Upload failed');
}
} catch (error) {
status.textContent = `✗ Error: ${error.message}`;
}
});
</script>
```
---
## 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<Response> {
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/

View File

@@ -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
<!-- AVIF (best compression) -->
<img src="/cdn-cgi/image/format=avif/image.jpg" />
<!-- WebP (good compression, wide support) -->
<img src="/cdn-cgi/image/format=webp/image.jpg" />
<!-- JPEG (progressive) -->
<img src="/cdn-cgi/image/format=jpeg/image.jpg" />
<!-- Baseline JPEG (older devices) -->
<img src="/cdn-cgi/image/format=baseline-jpeg/image.jpg" />
```
### 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
<picture>
<!-- Explicit AVIF for modern browsers -->
<source
type="image/avif"
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=avif 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=avif 1920w
"
/>
<!-- WebP fallback -->
<source
type="image/webp"
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=webp 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=webp 1920w
"
/>
<!-- JPEG fallback -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=jpeg 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=jpeg 1920w
"
src="https://imagedelivery.net/HASH/ID/w=1920,f=jpeg"
alt="Responsive image with format fallbacks"
/>
</picture>
<!-- OR: Let format=auto handle it -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
"
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="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
<script>
// Check AVIF support
const avifSupport = document.createElement('canvas')
.toDataURL('image/avif').indexOf('data:image/avif') === 0;
// Check WebP support
const webpSupport = document.createElement('canvas')
.toDataURL('image/webp').indexOf('data:image/webp') === 0;
console.log('AVIF:', avifSupport); // true in Chrome/Edge
console.log('WebP:', webpSupport); // true in modern browsers
</script>
```
**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

View File

@@ -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
<img
srcset="
https://imagedelivery.net/HASH/ID/mobile 480w,
https://imagedelivery.net/HASH/ID/tablet 768w,
https://imagedelivery.net/HASH/ID/desktop 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/desktop"
alt="Responsive image"
loading="lazy"
/>
```
**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
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1920,f=auto 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="https://imagedelivery.net/HASH/ID/w=1920,f=auto"
alt="Responsive image"
loading="lazy"
/>
```
---
## Art Direction (Different Crops)
Serve different image crops for mobile vs desktop.
```html
<picture>
<!-- Mobile: Square crop -->
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/mobile-square"
/>
<!-- Desktop: Wide crop -->
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/desktop-wide"
/>
<!-- Fallback -->
<img
src="https://imagedelivery.net/HASH/ID/desktop-wide"
alt="Art directed image"
loading="lazy"
/>
</picture>
```
**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
<img
srcset="
https://imagedelivery.net/HASH/ID/w=400,dpr=1,f=auto 1x,
https://imagedelivery.net/HASH/ID/w=400,dpr=2,f=auto 2x
"
src="https://imagedelivery.net/HASH/ID/w=400,f=auto"
alt="Retina-ready image"
/>
```
---
## Blur Placeholder (LQIP)
Load tiny blurred placeholder first, then swap to full image.
```html
<img
id="lqip-image"
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"
style="filter: blur(10px); transition: filter 0.3s;"
/>
<script>
const img = document.getElementById('lqip-image');
const fullSrc = img.getAttribute('data-src');
const fullImg = new Image();
fullImg.src = fullSrc;
fullImg.onload = () => {
img.src = fullSrc;
img.style.filter = 'blur(0)';
};
</script>
```
---
## Lazy Loading
Defer loading below-the-fold images.
```html
<!-- Native lazy loading (modern browsers) -->
<img src="..." loading="lazy" alt="..." />
<!-- With Intersection Observer (better control) -->
<img
class="lazy"
data-src="https://imagedelivery.net/HASH/ID/w=800,f=auto"
alt="Lazy loaded image"
/>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
document.querySelectorAll('img.lazy').forEach(img => observer.observe(img));
</script>
```
---
## URL Transformations (/cdn-cgi/image/)
Transform ANY publicly accessible image (not just Cloudflare Images storage).
```html
<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"
src="/cdn-cgi/image/width=1920,format=auto/uploads/photo.jpg"
alt="Transformed origin image"
loading="lazy"
/>
```
---
## 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
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
img { max-width: 100%; height: auto; display: block; }
</style>
</head>
<body>
<!-- Hero image with art direction -->
<picture>
<source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/HASH/ID/w=480,h=480,fit=cover,f=auto"
/>
<source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
/>
<img
src="https://imagedelivery.net/HASH/ID/w=1920,h=1080,fit=cover,f=auto"
alt="Hero image"
/>
</picture>
<!-- Responsive gallery images -->
<img
srcset="
https://imagedelivery.net/HASH/ID/w=480,f=auto 480w,
https://imagedelivery.net/HASH/ID/w=768,f=auto 768w,
https://imagedelivery.net/HASH/ID/w=1024,f=auto 1024w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw
"
src="https://imagedelivery.net/HASH/ID/w=1024,f=auto"
alt="Gallery image"
loading="lazy"
/>
</body>
</html>
```
---
## 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/

View File

@@ -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/<HASH>/<ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
```
**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<string> {
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<string> {
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<string> {
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<Response> {
// 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<Record<string, string>> {
const urls: Record<string, string> = {};
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<boolean> {
// 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/

476
references/top-errors.md Normal file
View File

@@ -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<Response> {
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<Response> {
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/<HASH>/<SVG_ID>/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/

View File

@@ -0,0 +1,337 @@
# Image Transformation Options
Complete reference for all image transformation parameters.
Works with:
- **URL format**: `/cdn-cgi/image/<OPTIONS>/<SOURCE>`
- **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/

View File

@@ -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 <API_TOKEN>" \
--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/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT_NAME>
```
**Example**:
```html
<img src="https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/abc123/thumbnail" />
```
### 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 <API_TOKEN>" \
--header "Content-Type: application/json" \
--data '{"flexible_variants": true}'
```
### Using Flexible Variants
**URL Format**:
```
https://imagedelivery.net/<ACCOUNT_HASH>/<IMAGE_ID>/<TRANSFORMATION_PARAMS>
```
**Examples**:
```html
<!-- Basic resize -->
<img src="https://imagedelivery.net/HASH/ID/w=400,h=300" />
<!-- With quality and format -->
<img src="https://imagedelivery.net/HASH/ID/w=800,q=85,f=auto" />
<!-- Sharpen and crop -->
<img src="https://imagedelivery.net/HASH/ID/w=600,h=600,fit=cover,sharpen=3" />
<!-- Blur effect -->
<img src="https://imagedelivery.net/HASH/ID/w=500,blur=20,q=50" />
```
### 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
<!-- Named variant for avatar (private image, signed URL) -->
<img src="https://imagedelivery.net/HASH/PRIVATE_ID/avatar?exp=123&sig=abc" />
<!-- Flexible variant for public thumbnail -->
<img src="https://imagedelivery.net/HASH/PUBLIC_ID/w=300,h=300,fit=cover" />
```
---
## 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/

106
scripts/check-versions.sh Executable file
View File

@@ -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}"

308
templates/batch-upload.ts Normal file
View File

@@ -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<string, string>;
}
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<CloudflareImagesResponse> {
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<CloudflareImagesResponse> {
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<string, string> }>,
concurrency: number = 5,
env: Env
): Promise<Array<{ input: unknown; result?: CloudflareImagesResponse; error?: string }>> {
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<string, string>;
} = {},
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<Response> {
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)
*/

View File

@@ -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<string, string>;
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<DirectUploadResponse> {
const requestBody: Record<string, unknown> = {};
// 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<Response> {
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);
* ```
*/

View File

@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Direct Creator Upload - Cloudflare Images</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
h1 { margin-bottom: 2rem; }
.upload-form { display: flex; flex-direction: column; gap: 1rem; }
.file-input-wrapper {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.file-input-wrapper:hover { border-color: #007bff; background: #f8f9fa; }
.file-input-wrapper.dragover { border-color: #28a745; background: #e7f5e9; }
input[type="file"] { display: none; }
button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) { background: #0056b3; }
button:disabled { background: #6c757d; cursor: not-allowed; }
.progress {
height: 30px;
background: #e9ecef;
border-radius: 6px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: #28a745;
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
}
.message {
padding: 1rem;
border-radius: 6px;
display: none;
}
.message.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.preview { margin-top: 1rem; max-width: 100%; border-radius: 6px; display: none; }
</style>
</head>
<body>
<h1>Upload Image to Cloudflare</h1>
<form id="upload-form" class="upload-form">
<label for="file-input" class="file-input-wrapper" id="drop-zone">
<div>
<p><strong>Choose a file</strong> or drag it here</p>
<p style="margin-top: 0.5rem; color: #666;">Max 10MB, JPEG/PNG/WebP/GIF</p>
</div>
<input type="file" id="file-input" accept="image/*" />
</label>
<div id="file-name" style="color: #666;"></div>
<img id="preview" class="preview" alt="Preview" />
<button type="submit" id="upload-btn" disabled>Upload Image</button>
<div class="progress" id="progress">
<div class="progress-bar" id="progress-bar">0%</div>
</div>
<div id="message" class="message"></div>
</form>
<script>
// Configuration
const API_ENDPOINT = '/api/upload-url'; // Your backend endpoint
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Elements
const form = document.getElementById('upload-form');
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const fileName = document.getElementById('file-name');
const preview = document.getElementById('preview');
const uploadBtn = document.getElementById('upload-btn');
const progress = document.getElementById('progress');
const progressBar = document.getElementById('progress-bar');
const message = document.getElementById('message');
let selectedFile = null;
// File input change
fileInput.addEventListener('change', handleFileSelect);
// Drag and drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
handleFileSelect();
}
});
// Handle file selection
function handleFileSelect() {
selectedFile = fileInput.files[0];
if (!selectedFile) {
return;
}
// Validate file size
if (selectedFile.size > MAX_FILE_SIZE) {
showMessage(`File too large (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB). Max 10MB.`, 'error');
resetForm();
return;
}
// Validate file type
if (!selectedFile.type.startsWith('image/')) {
showMessage('Please select an image file.', 'error');
resetForm();
return;
}
// Show file name
fileName.textContent = `Selected: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)}MB)`;
// Show preview
const reader = new FileReader();
reader.onload = (e) => {
preview.src = e.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(selectedFile);
// Enable upload button
uploadBtn.disabled = false;
}
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) {
showMessage('Please select a file', 'error');
return;
}
try {
// Disable form
uploadBtn.disabled = true;
fileInput.disabled = true;
progress.style.display = 'block';
message.style.display = 'none';
// Step 1: Get upload URL from backend
showProgress(10, 'Requesting upload URL...');
const urlResponse = await fetch(API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 'user-123', // Replace with actual user ID
requireSignedURLs: false
})
});
if (!urlResponse.ok) {
throw new Error('Failed to get upload URL');
}
const { uploadURL, imageId } = await urlResponse.json();
// Step 2: Upload directly to Cloudflare
showProgress(30, 'Uploading...');
const formData = new FormData();
formData.append('file', selectedFile); // MUST be named 'file'
const uploadResponse = await fetch(uploadURL, {
method: 'POST',
body: formData
// NO Content-Type header - browser sets multipart/form-data automatically
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
const uploadResult = await uploadResponse.json();
showProgress(100, 'Complete!');
// Success
setTimeout(() => {
showMessage(`✓ Upload successful! Image ID: ${imageId}`, 'success');
progress.style.display = 'none';
}, 500);
} catch (error) {
console.error('Upload error:', error);
showMessage(`✗ Upload failed: ${error.message}`, 'error');
progress.style.display = 'none';
uploadBtn.disabled = false;
fileInput.disabled = false;
}
});
// Helper: Show progress
function showProgress(percent, text) {
progressBar.style.width = `${percent}%`;
progressBar.textContent = text || `${percent}%`;
}
// Helper: Show message
function showMessage(text, type) {
message.textContent = text;
message.className = `message ${type}`;
message.style.display = 'block';
}
// Helper: Reset form
function resetForm() {
selectedFile = null;
fileInput.value = '';
fileName.textContent = '';
preview.style.display = 'none';
uploadBtn.disabled = true;
}
</script>
</body>
</html>
<!--
CRITICAL CORS FIX:
✅ CORRECT:
const formData = new FormData();
formData.append('file', selectedFile); // Name MUST be 'file'
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 })
});
ARCHITECTURE:
1. Frontend → POST /api/upload-url → Backend
2. Backend → POST /direct_upload → Cloudflare API
3. Backend → Returns uploadURL → Frontend
4. Frontend → Uploads to uploadURL → Cloudflare
5. Cloudflare → Returns success → Frontend
WHY:
- No API key exposure to browser
- Users upload directly to Cloudflare (faster)
- multipart/form-data required (CORS)
- Field name MUST be 'file'
-->

16
templates/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Responsive Images with Cloudflare Images</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
h1 { margin-bottom: 2rem; }
section { margin-bottom: 3rem; }
h2 { margin-bottom: 1rem; color: #333; }
p { margin-bottom: 1rem; color: #666; }
img { max-width: 100%; height: auto; display: block; border-radius: 8px; }
.code-block { background: #f5f5f5; padding: 1rem; border-radius: 6px; margin-top: 0.5rem; overflow-x: auto; }
</style>
</head>
<body>
<h1>Responsive Images with Cloudflare Images</h1>
<!-- Example 1: srcset with named variants -->
<section>
<h2>1. Using Named Variants</h2>
<p>Serve different image sizes based on viewport width using predefined variants.</p>
<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 with named variants"
loading="lazy"
/>
<div class="code-block">
<code>
&lt;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"
/&gt;
</code>
</div>
</section>
<!-- Example 2: srcset with flexible variants -->
<section>
<h2>2. Using Flexible Variants</h2>
<p>Dynamic transformations with format=auto for optimal WebP/AVIF delivery.</p>
<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"
alt="Responsive image with flexible variants"
loading="lazy"
/>
<div class="code-block">
<code>
&lt;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"
/&gt;
</code>
</div>
</section>
<!-- Example 3: Art direction with picture element -->
<section>
<h2>3. Art Direction (Different Crops)</h2>
<p>Serve different image crops for mobile vs desktop (e.g., portrait on mobile, landscape on desktop).</p>
<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"
loading="lazy"
/>
</picture>
<div class="code-block">
<code>
&lt;picture&gt;
&lt;source
media="(max-width: 767px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/mobile-square"
/&gt;
&lt;source
media="(min-width: 768px)"
srcset="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
/&gt;
&lt;img
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/desktop-wide"
alt="Art directed image"
/&gt;
&lt;/picture&gt;
</code>
</div>
</section>
<!-- Example 4: Retina displays -->
<section>
<h2>4. High-DPI (Retina) Displays</h2>
<p>Serve 2x images for high-resolution screens.</p>
<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"
alt="Retina-ready image"
loading="lazy"
/>
<div class="code-block">
<code>
&lt;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"
/&gt;
</code>
</div>
</section>
<!-- Example 5: Blur placeholder (LQIP) -->
<section>
<h2>5. Low-Quality Image Placeholder (LQIP)</h2>
<p>Load a tiny blurred placeholder first, then swap to full image.</p>
<img
id="lqip-image"
src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=50,q=10,blur=20,f=webp"
data-src="https://imagedelivery.net/YOUR_HASH/IMAGE_ID/w=1920,f=auto"
alt="Image with LQIP"
style="filter: blur(10px); transition: filter 0.3s;"
/>
<script>
const lqipImage = document.getElementById('lqip-image');
const fullImageURL = lqipImage.getAttribute('data-src');
// Load full-size image
const fullImage = new Image();
fullImage.src = fullImageURL;
fullImage.onload = () => {
lqipImage.src = fullImageURL;
lqipImage.style.filter = 'blur(0)';
};
</script>
<div class="code-block">
<code>
&lt;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"
/&gt;
&lt;script&gt;
const img = document.querySelector('img');
const fullImg = new Image();
fullImg.src = img.getAttribute('data-src');
fullImg.onload = () =&gt; { img.src = fullImg.src; };
&lt;/script&gt;
</code>
</div>
</section>
<!-- Example 6: URL transformations -->
<section>
<h2>6. Using URL Transformations (/cdn-cgi/image/)</h2>
<p>Transform ANY publicly accessible image (not just Cloudflare Images storage).</p>
<img
srcset="
/cdn-cgi/image/width=480,quality=85,format=auto/uploads/photo.jpg 480w,
/cdn-cgi/image/width=768,quality=85,format=auto/uploads/photo.jpg 768w,
/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg 1920w
"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="/cdn-cgi/image/width=1920,quality=85,format=auto/uploads/photo.jpg"
alt="Transformed image from origin"
loading="lazy"
/>
<div class="code-block">
<code>
&lt;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"
/&gt;
</code>
</div>
</section>
<!-- Tips -->
<section>
<h2>Best Practices</h2>
<ul style="color: #666; line-height: 1.8; padding-left: 1.5rem;">
<li>Always use <code>format=auto</code> for optimal WebP/AVIF delivery</li>
<li>Add <code>loading="lazy"</code> for images below the fold</li>
<li>Use <code>sizes</code> attribute to match your CSS layout</li>
<li>Provide descriptive <code>alt</code> text for accessibility</li>
<li>Consider art direction for different screen sizes (portrait vs landscape)</li>
<li>Use LQIP (Low-Quality Image Placeholder) for better perceived performance</li>
<li>Named variants: Best for consistent sizes and signed URLs</li>
<li>Flexible variants: Best for dynamic sizing (public images only)</li>
</ul>
</section>
</body>
</html>

View File

@@ -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/<ACCOUNT_HASH>/<IMAGE_ID>/<VARIANT>?exp=<EXPIRY>&sig=<SIGNATURE>
*/
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<string> {
// 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<string> {
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<Record<string, string>> {
const urls: Record<string, string> = {};
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<Response> {
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<string> {
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<boolean> {
// 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
*/

View File

@@ -0,0 +1,225 @@
/**
* Cloudflare Images - Transform via URL
*
* Transform images using the special URL format:
* /cdn-cgi/image/<OPTIONS>/<SOURCE-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<TransformOptions>
): 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 `
<img
srcset="${buildTransformURL(imagePath, { width: 480, format: 'auto' })} 480w,
${buildTransformURL(imagePath, { width: 768, format: 'auto' })} 768w,
${buildTransformURL(imagePath, { width: 1920, format: 'auto' })} 1920w"
sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
src="${buildTransformURL(imagePath, { width: 1920, format: 'auto' })}"
alt="${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
* <!-- Thumbnail -->
* <img src="/cdn-cgi/image/width=300,height=300,fit=cover,quality=85,format=auto/uploads/photo.jpg" />
*
* <!-- Smart crop to face -->
* <img src="/cdn-cgi/image/width=200,height=200,gravity=face,fit=cover/uploads/profile.jpg" />
*
* <!-- Blur effect for privacy -->
* <img src="/cdn-cgi/image/blur=20,quality=50/uploads/document.jpg" />
*
* <!-- Responsive with srcset -->
* <img
* srcset="/cdn-cgi/image/width=480,format=auto/uploads/hero.jpg 480w,
* /cdn-cgi/image/width=768,format=auto/uploads/hero.jpg 768w,
* /cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg 1920w"
* sizes="(max-width: 480px) 480px, (max-width: 768px) 768px, 1920px"
* src="/cdn-cgi/image/width=1920,format=auto/uploads/hero.jpg"
* />
* ```
*
* 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
*/

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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>
): 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.
*/

View File

@@ -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<string, string>; // 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<CloudflareImagesResponse> {
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<Response> {
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]);
* }
* ```
*/

207
templates/upload-via-url.ts Normal file
View File

@@ -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<string, string>;
}
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<CloudflareImagesResponse> {
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<Response> {
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<Array<{ url: string; result?: CloudflareImagesResponse; error?: string }>> {
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<CloudflareImagesResponse> {
// 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
* );
* ```
*/

View File

@@ -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<string, unknown> = { 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<Response> {
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<void> {
// 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
*/

View File

@@ -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"
}
]
}