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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir