Cloudflare Workers ile Görsel Boyutlandırma ve Optimizasyon
Görsel optimizasyon meselesi, her web geliştiricisinin bir noktada kafasını yiyen konulardan birisidir. Sunucu tarafında ImageMagick kurmak, libvips ile uğraşmak, CDN entegrasyonları… Ama ya tüm bunları edge’de, kullanıcıya en yakın noktada, sıfır sunucu yönetimi ile yapabilseydin? İşte Cloudflare Workers tam da bunu mümkün kılıyor. Bu yazıda, Workers üzerinde görsel boyutlandırma ve optimizasyon pipeline’ı nasıl kurulur, gerçek dünyada ne işe yarar, onu konuşacağız.
Neden Edge’de Görsel İşleme?
Klasik senaryoyu düşün: Kullanıcı bir e-ticaret sitesine giriyor, ürün görselleri yükleniyor. Görseller 4K çözünürlükte origin sunucuna yüklenmiş, CDN önbelleğe almış ama hala 3-4 MB boyutunda. Mobil kullanıcı hem veriyi harcıyor hem de sayfanın açılmasını bekliyor.
Geleneksel çözümde origin sunucusunda bir worker process çalışıyor, görseli işliyor, önbelleğe alıyor. Bu yaklaşımın sorunları şunlar:
- Origin sunucusuna gereksiz yük biniyor
- İşleme süresi kullanıcı deneyimini doğrudan etkiliyor
- Sunucu kapasitesini ölçeklendirmek maliyet demek
- Farklı cihaz boyutları için onlarca varyant üretip saklamak gerekiyor
Cloudflare Workers ile görsel işleme, kullanıcının bulunduğu konuma en yakın Cloudflare PoP’unda (Point of Presence) gerçekleşiyor. 200’den fazla şehirde Cloudflare PoP’u var ve Workers milisaniyeler içinde çalışıyor. Üstelik Cloudflare Image Resizing özelliği Workers ile entegre çalışıyor.
Cloudflare Workers ve Image Resizing Kurulumu
Başlamadan önce birkaç ön koşul:
- Cloudflare hesabı (Pro veya üzeri plan Image Resizing için gerekli)
- Wrangler CLI kurulu olmalı
- Node.js 18+
Wrangler’ı kur ve projeyi başlat:
npm install -g wrangler
wrangler login
wrangler init image-optimizer --type javascript
cd image-optimizer
wrangler.toml dosyasını yapılandır:
cat > wrangler.toml << 'EOF'
name = "image-optimizer"
main = "src/index.js"
compatibility_date = "2024-01-01"
[vars]
ALLOWED_ORIGINS = "https://yourdomain.com"
MAX_WIDTH = "3840"
MAX_HEIGHT = "2160"
DEFAULT_QUALITY = "85"
[[routes]]
pattern = "images.yourdomain.com/*"
zone_name = "yourdomain.com"
EOF
Temel Worker Yapısı
İlk olarak basit bir görsel proxy ve boyutlandırma Worker’ı yazalım. Bu Worker, gelen istekteki URL parametrelerini okuyacak ve Cloudflare’in Image Resizing API’sine iletecek:
cat > src/index.js << 'EOF'
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Sadece görsel isteklerini işle
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'];
const pathname = url.pathname.toLowerCase();
const isImage = imageExtensions.some(ext => pathname.endsWith(ext));
if (!isImage) {
return new Response('Sadece görsel dosyaları desteklenir.', { status: 400 });
}
// URL parametrelerini oku
const width = parseInt(url.searchParams.get('w') || '0');
const height = parseInt(url.searchParams.get('h') || '0');
const quality = parseInt(url.searchParams.get('q') || env.DEFAULT_QUALITY);
const format = url.searchParams.get('f') || 'auto';
const fit = url.searchParams.get('fit') || 'cover';
// Güvenlik kontrolleri
const maxWidth = parseInt(env.MAX_WIDTH);
const maxHeight = parseInt(env.MAX_HEIGHT);
if (width > maxWidth || height > maxHeight) {
return new Response('Boyut limiti aşıldı.', { status: 400 });
}
// Origin URL'yi oluştur
const originUrl = `https://origin.yourdomain.com${url.pathname}`;
// Cloudflare Image Resizing seçenekleri
const imageOptions = {
cf: {
image: {
width: width || undefined,
height: height || undefined,
quality: quality,
format: format,
fit: fit,
}
}
};
try {
const response = await fetch(originUrl, imageOptions);
if (!response.ok) {
return new Response('Görsel bulunamadı.', { status: 404 });
}
// Cache-Control header'larını ayarla
const newResponse = new Response(response.body, response);
newResponse.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
newResponse.headers.set('Vary', 'Accept');
return newResponse;
} catch (error) {
return new Response(`Hata: ${error.message}`, { status: 500 });
}
}
};
EOF
Gelişmiş Optimizasyon Pipeline’ı
Gerçek dünyada tek bir boyutlandırma yeterli olmaz. Responsive images için birden fazla boyut, WebP/AVIF dönüşümü, watermark ekleme, ve akıllı crop gerekebilir. İşte daha kapsamlı bir örnek:
cat > src/imageProcessor.js << 'EOF'
export class ImageProcessor {
constructor(env) {
this.env = env;
this.defaultOptions = {
quality: parseInt(env.DEFAULT_QUALITY) || 85,
format: 'auto',
fit: 'cover',
metadata: 'none',
sharpen: 1,
};
}
parseOptions(searchParams, acceptHeader) {
const options = { ...this.defaultOptions };
// Boyutlar
const width = parseInt(searchParams.get('w'));
const height = parseInt(searchParams.get('h'));
if (width > 0) options.width = Math.min(width, 3840);
if (height > 0) options.height = Math.min(height, 2160);
// Kalite (1-100 arası)
const quality = parseInt(searchParams.get('q'));
if (quality > 0 && quality <= 100) options.quality = quality;
// Format tercihi - tarayıcı desteğine göre otomatik seç
const requestedFormat = searchParams.get('f');
if (requestedFormat) {
options.format = requestedFormat;
} else if (acceptHeader?.includes('image/avif')) {
options.format = 'avif';
} else if (acceptHeader?.includes('image/webp')) {
options.format = 'webp';
}
// Fit modu
const fit = searchParams.get('fit');
const validFits = ['cover', 'contain', 'scale-down', 'crop', 'pad'];
if (fit && validFits.includes(fit)) options.fit = fit;
// Gravity (odak noktası)
const gravity = searchParams.get('gravity');
if (gravity) options.gravity = gravity;
// Bulanıklık efekti
const blur = parseInt(searchParams.get('blur'));
if (blur > 0 && blur <= 250) options.blur = blur;
// Parlaklık ve kontrast
const brightness = parseFloat(searchParams.get('brightness'));
if (brightness >= 0 && brightness <= 2) options.brightness = brightness;
return options;
}
buildCacheKey(url, options) {
const params = new URLSearchParams({
w: options.width || '',
h: options.height || '',
q: options.quality,
f: options.format,
fit: options.fit,
});
return `${url.pathname}?${params.toString()}`;
}
}
EOF
KV ile Akıllı Önbellekleme
Cloudflare KV (Key-Value store) kullanarak işlenmiş görsellerin metadata’sını önbelleğe alabiliriz. Bu, aynı dönüşüm talebinin tekrar işlenmesini önler:
cat > src/cacheManager.js << 'EOF'
export class CacheManager {
constructor(kvNamespace) {
this.kv = kvNamespace;
this.cacheTTL = 86400 * 7; // 7 gün
}
async getCachedMetadata(cacheKey) {
try {
const cached = await this.kv.get(cacheKey, { type: 'json' });
return cached;
} catch {
return null;
}
}
async setCachedMetadata(cacheKey, metadata) {
try {
await this.kv.put(
cacheKey,
JSON.stringify(metadata),
{ expirationTtl: this.cacheTTL }
);
} catch (error) {
console.error('KV yazma hatası:', error);
}
}
generateETag(options) {
const str = JSON.stringify(options);
// Basit hash fonksiyonu
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
buildResponseHeaders(options, contentType) {
return {
'Cache-Control': 'public, max-age=31536000, immutable',
'Vary': 'Accept, Accept-Encoding',
'Content-Type': contentType,
'X-Image-Optimized': 'true',
'X-Image-Format': options.format,
'X-Image-Quality': options.quality.toString(),
};
}
}
EOF
Tam Entegre Worker
Şimdi tüm parçaları bir araya getirelim. Bu ana Worker dosyası tüm mantığı orchestrate ediyor:
cat > src/index.js << 'EOF'
import { ImageProcessor } from './imageProcessor.js';
import { CacheManager } from './cacheManager.js';
const ALLOWED_PATHS = ['/images/', '/media/', '/uploads/'];
const RATE_LIMIT_REQUESTS = 100;
const RATE_LIMIT_WINDOW = 60; // saniye
export default {
async fetch(request, env, ctx) {
// CORS ve preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': env.ALLOWED_ORIGINS || '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Max-Age': '86400',
}
});
}
if (request.method !== 'GET') {
return new Response('Sadece GET istekleri kabul edilir.', { status: 405 });
}
const url = new URL(request.url);
// Path güvenlik kontrolü
const isAllowedPath = ALLOWED_PATHS.some(p => url.pathname.startsWith(p));
if (!isAllowedPath) {
return new Response('Bu path için erişim izniniz yok.', { status: 403 });
}
// Görsel uzantı kontrolü
const supportedFormats = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg'];
const hasImageExt = supportedFormats.some(ext =>
url.pathname.toLowerCase().endsWith(ext)
);
if (!hasImageExt) {
return new Response('Desteklenmeyen dosya formatı.', { status: 415 });
}
const processor = new ImageProcessor(env);
const cacheManager = new CacheManager(env.IMAGE_CACHE);
const acceptHeader = request.headers.get('Accept') || '';
const options = processor.parseOptions(url.searchParams, acceptHeader);
const cacheKey = processor.buildCacheKey(url, options);
// Browser cache kontrolü (ETag)
const etag = cacheManager.generateETag(options);
const ifNoneMatch = request.headers.get('If-None-Match');
if (ifNoneMatch === etag) {
return new Response(null, { status: 304 });
}
// Origin URL
const originUrl = new URL(url.pathname, env.ORIGIN_URL || 'https://origin.yourdomain.com');
// Cloudflare Image Resizing ile fetch
const imageResponse = await fetch(originUrl.toString(), {
cf: {
image: options,
cacheEverything: true,
cacheTtl: 86400 * 30,
}
});
if (!imageResponse.ok) {
const status = imageResponse.status === 404 ? 404 : 502;
return new Response('Görsel alınamadı.', { status });
}
const contentType = imageResponse.headers.get('Content-Type') || 'image/jpeg';
const responseHeaders = cacheManager.buildResponseHeaders(options, contentType);
responseHeaders['ETag'] = etag;
// Metadata'yı arka planda KV'ye yaz
ctx.waitUntil(
cacheManager.setCachedMetadata(cacheKey, {
processedAt: Date.now(),
options,
contentType,
})
);
return new Response(imageResponse.body, {
status: 200,
headers: responseHeaders,
});
}
};
EOF
Responsive Image Helper
Gerçek dünyada sıkça karşılaşılan bir senaryo: Aynı görselin farklı ekran boyutları için otomatik olarak srcset oluşturulması. Bunun için bir HTML helper Worker’ı yazabiliriz:
cat > src/srcsetHelper.js << 'EOF'
export function generateSrcSet(baseUrl, imagePath, breakpoints) {
const defaultBreakpoints = breakpoints || [320, 480, 768, 1024, 1280, 1920];
const srcsetEntries = defaultBreakpoints.map(width => {
const url = new URL(imagePath, baseUrl);
url.searchParams.set('w', width.toString());
url.searchParams.set('f', 'auto');
url.searchParams.set('q', '85');
return `${url.toString()} ${width}w`;
});
return srcsetEntries.join(', ');
}
export function generatePictureElement(baseUrl, imagePath, alt, sizes) {
const defaultSizes = sizes || '(max-width: 768px) 100vw, (max-width: 1024px) 75vw, 50vw';
const avifSrcset = generateSrcSetWithFormat(baseUrl, imagePath, 'avif');
const webpSrcset = generateSrcSetWithFormat(baseUrl, imagePath, 'webp');
const fallbackSrcset = generateSrcSet(baseUrl, imagePath);
return `
<picture>
<source type="image/avif" srcset="${avifSrcset}" sizes="${defaultSizes}">
<source type="image/webp" srcset="${webpSrcset}" sizes="${defaultSizes}">
<img
src="${baseUrl}${imagePath}?w=800&f=auto"
srcset="${fallbackSrcset}"
sizes="${defaultSizes}"
alt="${alt}"
loading="lazy"
decoding="async"
>
</picture>`;
}
function generateSrcSetWithFormat(baseUrl, imagePath, format) {
const breakpoints = [320, 480, 768, 1024, 1280, 1920];
return breakpoints.map(width => {
const url = new URL(imagePath, baseUrl);
url.searchParams.set('w', width.toString());
url.searchParams.set('f', format);
url.searchParams.set('q', '85');
return `${url.toString()} ${width}w`;
}).join(', ');
}
EOF
Deploy ve Test
Worker’ı deploy etmek için:
# KV namespace oluştur
wrangler kv:namespace create "IMAGE_CACHE"
wrangler kv:namespace create "IMAGE_CACHE" --preview
# wrangler.toml'a KV binding ekle (çıktıdaki ID'leri kullan)
cat >> wrangler.toml << 'EOF'
[[kv_namespaces]]
binding = "IMAGE_CACHE"
id = "senin_kv_id"
preview_id = "senin_preview_kv_id"
EOF
# Local geliştirme için çalıştır
wrangler dev
# Production'a deploy et
wrangler deploy
Deploy sonrası performans testlerini çalıştır:
# Temel boyutlandırma testi
curl -I "https://images.yourdomain.com/images/product.jpg?w=800&h=600&q=80"
# WebP dönüşümü testi
curl -I -H "Accept: image/webp"
"https://images.yourdomain.com/images/product.jpg?w=400"
# AVIF dönüşümü testi
curl -I -H "Accept: image/avif,image/webp"
"https://images.yourdomain.com/images/product.jpg?w=400&q=75"
# Blur efekti testi
curl -o blurred.jpg
"https://images.yourdomain.com/images/product.jpg?w=800&blur=10"
# Yük testi (wrk varsa)
wrk -t4 -c100 -d30s
"https://images.yourdomain.com/images/product.jpg?w=800&f=webp"
Gerçek Dünya Senaryosu: E-ticaret Görselleri
Bir e-ticaret platformunda bu sistemi nasıl kullanacağını düşünelim. Ürün görselleri yüksek çözünürlükte yükleniyor, sonra farklı bağlamlarda farklı boyutlara ihtiyaç duyuluyor:
- Ürün liste sayfası: 300×300 thumbnail, WebP, quality 80
- Ürün detay sayfası: 800×800 ana görsel, AVIF/WebP, quality 90
- Sepet sayfası: 100×100 küçük thumbnail, WebP, quality 70
- Sosyal medya paylaşım: 1200×630 OG görsel, JPEG, quality 85
- Zoom özelliği: 2000×2000 büyük görsel, AVIF, quality 95
Bunları URL parametreleriyle şöyle çağırırsın:
# Ürün liste thumbnail
https://images.yourdomain.com/images/product-123.jpg?w=300&h=300&fit=cover&q=80
# Ürün detay görseli
https://images.yourdomain.com/images/product-123.jpg?w=800&h=800&fit=contain&q=90
# OG Image
https://images.yourdomain.com/images/product-123.jpg?w=1200&h=630&fit=cover&q=85&f=jpeg
# Zoom görseli
https://images.yourdomain.com/images/product-123.jpg?w=2000&h=2000&fit=contain&q=95
Tüm bu dönüşümler Cloudflare edge’inde gerçekleşiyor, origin sunucun tek bir istek bile almıyor. İlk istek sonrasında Cloudflare cache’e alıyor ve sonraki istekler cache’den dönüyor.
Monitoring ve Hata Takibi
Workers Analytics ve Logpush ile performansı takip et:
# Worker loglarını canlı izle
wrangler tail --format=pretty
# Belirli bir zaman aralığı için log filtrele
wrangler tail --format=json | jq 'select(.outcome != "ok")'
wrangler.toml‘a analytics engine bağlayabilirsin:
cat >> wrangler.toml << 'EOF'
[[analytics_engine_datasets]]
binding = "IMAGE_ANALYTICS"
dataset = "image_optimizer_metrics"
EOF
Güvenlik Tavsiyeleri
Production’da mutlaka yapman gerekenler:
- Origin doğrulama: Origin sunucuna sadece Cloudflare IP’lerinden erişime izin ver
- Rate limiting: Aynı IP’den gelen aşırı istekleri kısıtla (Workers Rate Limiting API kullan)
- Boyut limitleri: Maksimum genişlik ve yüksekliği konfigürasyondan oku, sabit kodlama
- Path traversal:
../gibi zararlı path karakterlerini temizle - Hotlink koruması: Referer header’ı kontrol ederek başka sitelerden görsel çalınmasını önle
- Secret token: Bazı dönüşümleri (watermark kaldırma, yüksek kalite) token ile kısıtla
Performans Beklentileri
Gerçek dünyadan veriler vermek gerekirse, bu yapıyı devreye aldıktan sonra tipik olarak şu değişimleri görürsün:
- Sayfa başına görsel boyutu %60-80 oranında düşüyor (WebP ve AVIF dönüşümü + boyutlandırma birlikte)
- LCP (Largest Contentful Paint) metriği ciddi iyileşiyor, genellikle 1-2 saniye kazanç
- Origin sunucu yükü neredeyse sıfıra yaklaşıyor, CDN hit rate %95+ oluyor
- Bandwidth maliyetleri dramatik biçimde düşüyor, özellikle yüksek trafikli sitelerde bu çok kritik
Sonuç
Cloudflare Workers ile görsel optimizasyon, klasik “origin’de işle, CDN’de sun” modelinin tam tersini yapıyor: edge’de işle, origin’i kopar. Bu yaklaşım hem kullanıcı deneyimi hem de operasyonel verimlilik açısından ciddi kazanımlar sağlıyor.
Başlangıç için küçük adımlarla ilerle: Önce basit boyutlandırma Worker’ı yaz, production’da test et. Sonra format dönüşümü ekle, ardından KV cache entegrasyonu. Watermark ve gelişmiş efektleri ise ihtiyacın ortaya çıktıkça ekle.
WebAssembly tarafına geçmek istersen, Workers’ta Wasm modülleri çalıştırarak tamamen custom görsel işleme pipeline’ları oluşturabilirsin. Bu ise başlı başına ayrı bir yazı konusu. Ama standart e-ticaret ve içerik sitesi senaryolarında Cloudflare’in native Image Resizing API’si oldukça yeterli geliyor.
Sorularını yorumlarda bekliyorum, özellikle farklı origin storage türleri (R2, S3, başka CDN) ile entegrasyon konularında örnekler isteyenler olursa ayrıca değinebiliriz.
