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

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