Files
gh-jezweb-claude-skills-ski…/templates/transform-via-workers.ts
2025-11-30 08:24:18 +08:00

332 lines
8.7 KiB
TypeScript

/**
* 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.
*/