지리적 라우팅이 엣지에 속하는 이유

지리적 위치를 기반으로 콘텐츠를 개인화하는 것은 글로벌 웹 애플리케이션의 가장 일반적인 요구사항: 현지 통화, 국가별 규정(유럽의 경우 GDPR, 캘리포니아의 CCPA), 특정 관할권의 콘텐츠를 차단하고 리디렉션합니다. 지역 도메인을 향해.

역사적으로 이는 서버 측 지리 위치 데이터베이스로 처리되었습니다. (MaxMind GeoLite2) 또는 수동으로 구성된 CDN 규칙을 사용합니다. 두 가지 접근 방식 모두 제한 사항이 있습니다. 데이터베이스 서버가 대기 시간을 추가하고 CDN 규칙은 정적입니다. 동적으로 업데이트하기가 어렵습니다.

Cloudflare Workers를 사용하면 지리적 위치가 이미 사용 가능 개체에 request.cf 유지 관리할 데이터베이스가 없습니다. Cloudflare는 네트워크의 BGP 토폴로지를 기반으로 위치를 결정합니다. IP 조회가 아니며 국가 수준 정확도가 99.9% 이상입니다.

무엇을 배울 것인가

  • 다음에서 사용 가능한 속성 request.cf 지리적 위치에 대한
  • 지오펜싱: 국가별 차단 및 리디렉션
  • 현지화된 가격: 지역에 따른 통화 및 VAT
  • GDPR 준수: EU 사용자를 위한 자동 쿠키 동의
  • 사용자 정의 헤더를 사용한 다중 지역 라우팅
  • 배포 없이 지역 기반 논리 테스트

객체 request.cf Cloudflare 제공

Cloudflare 작업자에 대한 각 요청에는 제목이 포함됩니다. cf 와 Cloudflare가 실시간으로 결정하는 지리적 및 네트워크 메타데이터:

// Tutte le proprieta disponibili in request.cf

export default {
  async fetch(request: Request): Promise<Response> {
    const cf = request.cf as CfProperties;

    // Geolocalizzazione
    const country = cf.country;         // "IT" - ISO 3166-1 alpha-2
    const region = cf.region;           // "Puglia" - nome della regione
    const regionCode = cf.regionCode;   // "75" - codice regione
    const city = cf.city;               // "Bari"
    const postalCode = cf.postalCode;   // "70121"
    const latitude = cf.latitude;       // "41.1171"
    const longitude = cf.longitude;     // "16.8719"
    const timezone = cf.timezone;       // "Europe/Rome"
    const continent = cf.continent;     // "EU"

    // Rete
    const asn = cf.asn;                 // 1234 - Autonomous System Number
    const asOrganization = cf.asOrganization; // "Telecom Italia"
    const isEuCountry = cf.isEUCountry;       // "1" o "0"

    // Performance
    const colo = cf.colo;               // "FCO" - datacenter Cloudflare piu vicino
    const httpProtocol = cf.httpProtocol; // "HTTP/2"
    const tlsVersion = cf.tlsVersion;    // "TLSv1.3"

    return Response.json({
      country,
      region,
      city,
      timezone,
      continent,
      isEuCountry,
      colo,
    });
  },
};

// Tipo per le proprieta cf (parziale)
interface CfProperties {
  country?: string;
  region?: string;
  regionCode?: string;
  city?: string;
  postalCode?: string;
  latitude?: string;
  longitude?: string;
  timezone?: string;
  continent?: string;
  asn?: number;
  asOrganization?: string;
  isEUCountry?: string;
  colo?: string;
  httpProtocol?: string;
  tlsVersion?: string;
}

지오펜싱: 국가별 차단

지오펜싱(Geo-fencing)은 콘텐츠를 특정 사이트로 차단하거나 리디렉션하는 패턴입니다. 국가. 가장 일반적인 사용 사례는 국제 제재를 위한 차단, 지역 라이선스(스트리밍, 미디어)가 있는 콘텐츠 및 아직 시장이 확보되지 않은 콘텐츠 특정 제품에 적용 가능:

// src/geo-fence-worker.ts

// Paesi con accesso bloccato (esempio: sanzioni, licenze)
const BLOCKED_COUNTRIES = new Set(['KP', 'IR', 'SY', 'CU']);

// Paesi che richiedono un redirect a una versione localizzata
const REDIRECTS: Record<string, string> = {
  DE: 'https://de.example.com',
  FR: 'https://fr.example.com',
  JP: 'https://jp.example.com',
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as CfProperties;
    const country = cf.country ?? 'US';
    const url = new URL(request.url);

    // Blocco per paesi non consentiti
    if (BLOCKED_COUNTRIES.has(country)) {
      return new Response(
        JSON.stringify({
          error: 'Service not available in your region',
          country,
        }),
        {
          status: 451, // 451 Unavailable For Legal Reasons
          headers: {
            'Content-Type': 'application/json',
            'Vary': 'CF-IPCountry',
          },
        }
      );
    }

    // Redirect verso versione localizzata per certi paesi
    const redirectTarget = REDIRECTS[country];
    if (redirectTarget && !url.pathname.startsWith('/api/')) {
      const targetUrl = new URL(url.pathname + url.search, redirectTarget);
      return Response.redirect(targetUrl.toString(), 302);
    }

    // Aggiunge header con il paese per il downstream (server di origine)
    const headers = new Headers(request.headers);
    headers.set('CF-Worker-Country', country);
    headers.set('CF-Worker-Continent', cf.continent ?? '');
    headers.set('CF-Worker-Timezone', cf.timezone ?? '');

    // Prosegui verso il server di origine
    return fetch(new Request(request.url, {
      method: request.method,
      headers,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    }));
  },
};

interface CfProperties {
  country?: string;
  continent?: string;
  timezone?: string;
}

interface Env {}

현지화된 가격 및 통화

고객의 현지 통화로 가격을 표시하는 것이 가장 좋습니다. 전환율을 높이는 전자상거래입니다. 작업자를 사용하면 다음을 수행할 수 있습니다. 요청이 도달하기 전에 올바른 통화를 결정합니다. 원본 서버:

// src/pricing-worker.ts - prezzi localizzati all'edge

interface CurrencyConfig {
  code: string;
  symbol: string;
  position: 'before' | 'after';
  vatRate: number; // IVA in percentuale (0.22 = 22%)
}

const COUNTRY_CURRENCY: Record<string, CurrencyConfig> = {
  // Eurozona
  IT: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.22 },
  DE: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.19 },
  FR: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.20 },
  ES: { code: 'EUR', symbol: '€', position: 'before', vatRate: 0.21 },
  // Altre valute
  GB: { code: 'GBP', symbol: '£', position: 'before', vatRate: 0.20 },
  US: { code: 'USD', symbol: ', position: 'before', vatRate: 0 },
  JP: { code: 'JPY', symbol: '¥', position: 'before', vatRate: 0.10 },
  CH: { code: 'CHF', symbol: 'CHF', position: 'after', vatRate: 0.081 },
  DEFAULT: { code: 'USD', symbol: ', position: 'before', vatRate: 0 },
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string };
    const country = cf.country ?? 'US';
    const url = new URL(request.url);

    // Solo per le route del catalogo prodotti
    if (!url.pathname.startsWith('/api/products')) {
      return fetch(request);
    }

    // Recupera i prodotti dal server di origine (prezzi in USD base)
    const originResponse = await fetch(request);
    if (!originResponse.ok || !originResponse.headers.get('Content-Type')?.includes('json')) {
      return originResponse;
    }

    const products = await originResponse.json() as Product[];

    // Recupera il tasso di cambio (con cache in KV per 1 ora)
    const currency = COUNTRY_CURRENCY[country] ?? COUNTRY_CURRENCY['DEFAULT'];
    const exchangeRate = await getExchangeRate(currency.code, env);

    // Trasforma i prezzi
    const localizedProducts = products.map((product) => ({
      ...product,
      pricing: {
        baseUsd: product.priceUsd,
        local: {
          amount: convertPrice(product.priceUsd, exchangeRate),
          currency: currency.code,
          symbol: currency.symbol,
          vatIncluded: currency.vatRate > 0,
          vatRate: currency.vatRate,
          formatted: formatPrice(
            convertPrice(product.priceUsd, exchangeRate) * (1 + currency.vatRate),
            currency
          ),
        },
      },
    }));

    return Response.json(localizedProducts, {
      headers: {
        'Cache-Control': 'private, max-age=300',
        'Vary': 'CF-IPCountry',
        'X-Currency': currency.code,
        'X-Country': country,
      },
    });
  },
};

async function getExchangeRate(currency: string, env: Env): Promise<number> {
  if (currency === 'USD') return 1;

  // Prova dalla cache KV (evita richieste API continue)
  const cacheKey = `fx:${currency}`;
  const cached = await env.RATES_KV.get(cacheKey);
  if (cached) return parseFloat(cached);

  // Fallback: chiama un API di exchange rates
  const response = await fetch(
    `https://api.exchangerate.host/latest?base=USD&symbols=${currency}`,
    { cf: { cacheTtl: 3600 } } as RequestInit
  );
  const data = await response.json() as { rates: Record<string, number> };
  const rate = data.rates[currency] ?? 1;

  // Cache per 1 ora
  await env.RATES_KV.put(cacheKey, String(rate), { expirationTtl: 3600 });

  return rate;
}

function convertPrice(usd: number, rate: number): number {
  return Math.round(usd * rate * 100) / 100;
}

function formatPrice(amount: number, currency: CurrencyConfig): string {
  const formatted = new Intl.NumberFormat('en-US', {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(amount);

  return currency.position === 'before'
    ? `${currency.symbol}${formatted}`
    : `${formatted} ${currency.symbol}`;
}

interface Product {
  id: string;
  name: string;
  priceUsd: number;
}

interface Env {
  RATES_KV: KVNamespace;
}

엣지에서의 GDPR 규정 준수

GDPR은 유럽 연합에 거주하는 모든 사용자에게 적용됩니다. 서버 위치에 상관없이요. 가장 일반적인 접근 방식 다른 사람이 아닌 EU 사용자에게 쿠키 동의 배너를 표시하는 것입니다. 작업자를 사용하면 렌더링 전에 이 논리를 처리할 수 있습니다.

// src/gdpr-worker.ts - compliance GDPR all'edge

// Paesi UE + SEE che richiedono GDPR compliance
const GDPR_COUNTRIES = new Set([
  'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
  'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT',
  'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
  // SEE
  'IS', 'LI', 'NO',
  // UK post-Brexit mantiene UK-GDPR
  'GB',
]);

const CONSENT_COOKIE = 'gdpr_consent';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string; isEUCountry?: string };
    const country = cf.country ?? '';

    // Determina se l'utente e soggetto a GDPR
    // Usa sia il country code che il flag isEUCountry di Cloudflare
    const requiresGdpr = GDPR_COUNTRIES.has(country) || cf.isEUCountry === '1';

    // Controlla se l'utente ha gia dato il consenso
    const hasConsent = request.headers.get('Cookie')
      ?.split(';')
      .some((c) => c.trim().startsWith(`${CONSENT_COOKIE}=accepted`));

    // Aggiunge header per comunicare lo stato GDPR al server di origine
    // e ai server components Next.js
    const headers = new Headers(request.headers);
    headers.set('X-GDPR-Required', requiresGdpr ? '1' : '0');
    headers.set('X-GDPR-Consent', hasConsent ? 'accepted' : 'pending');
    headers.set('X-User-Country', country);

    // Per route API analytics: blocca tracking se non c'e consenso
    if (request.url.includes('/api/analytics') && requiresGdpr && !hasConsent) {
      return Response.json(
        { tracked: false, reason: 'consent_required' },
        { status: 200 } // Non e un errore, semplicemente non tracciamo
      );
    }

    // Rewrite per servire il banner GDPR nelle pagine HTML
    // Il server component legge l'header X-GDPR-Required per mostrare/nascondere il banner
    const response = await fetch(new Request(request.url, {
      method: request.method,
      headers,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    }));

    // Clona la risposta aggiungendo header utili per il caching
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('Vary', 'CF-IPCountry'); // Varia la cache per paese
    if (requiresGdpr) {
      // Non cachare le risposte per utenti EU senza consenso esplicito
      if (!hasConsent) {
        newResponse.headers.set('Cache-Control', 'private, no-store');
      }
    }

    return newResponse;
  },
};

interface Env {}

사용자 정의 헤더를 사용한 다중 지역 라우팅

고급 패턴은 작업자를 지리적 로드 밸런서로 사용하는 것입니다. 지능적으로 가장 가까운 데이터 센터로 요청 라우팅 측정된 대기 시간 및 비즈니스 규칙을 기반으로:

// src/geo-router-worker.ts - routing multi-regione

interface RegionConfig {
  origin: string;
  countries: string[];
  fallback?: string;
}

const REGIONS: RegionConfig[] = [
  {
    origin: 'https://api-eu.example.com',
    countries: ['IT', 'DE', 'FR', 'ES', 'PT', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'DK'],
  },
  {
    origin: 'https://api-us-east.example.com',
    countries: ['US', 'CA', 'MX', 'BR', 'AR'],
  },
  {
    origin: 'https://api-apac.example.com',
    countries: ['JP', 'KR', 'SG', 'AU', 'NZ', 'IN', 'TH', 'PH'],
  },
];

const DEFAULT_ORIGIN = 'https://api.example.com';

function selectOrigin(country: string): string {
  for (const region of REGIONS) {
    if (region.countries.includes(country)) {
      return region.origin;
    }
  }
  return DEFAULT_ORIGIN;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const cf = request.cf as { country?: string; colo?: string; continent?: string };
    const country = cf.country ?? 'US';

    const origin = selectOrigin(country);
    const url = new URL(request.url);
    const targetUrl = new URL(url.pathname + url.search, origin);

    // Aggiunge header per audit e debug
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('X-Forwarded-Country', country);
    requestHeaders.set('X-Edge-Colo', cf.colo ?? '');
    requestHeaders.set('X-Selected-Origin', origin);

    // Rimuove l'host originale e imposta quello del backend
    requestHeaders.delete('host');

    const response = await fetch(targetUrl.toString(), {
      method: request.method,
      headers: requestHeaders,
      body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
    });

    // Aggiunge header diagnostici alla risposta
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('X-Routed-To', origin);
    newResponse.headers.set('X-User-Country', country);

    return newResponse;
  },
};

interface Env {}

지역 기반 논리 테스트

지리적 라우팅을 로컬에서 테스트하려면 객체 시뮬레이션이 필요합니다. request.cf. 와 함께 wrangler dev 가능하다 맞춤 CF 값을 삽입합니다.

# Test locale con override del paese
wrangler dev --test-scheduled

# Nella richiesta HTTP, simula un paese specifico:
# Con wrangler dev, il cf object e simulato ma puoi sovrascrivere
# usando header custom e modificando il handler per development

# Oppure usa curl con header per test:
curl -H "CF-Connecting-IP: 151.28.0.1" http://localhost:8787/api/pricing

# Per test automatizzati con Vitest + Miniflare:
# Imposta cf nella request di test
// test/geo-routing.test.ts - test con Miniflare
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';

describe('Geo-fencing', () => {
  it('should block requests from blocked countries', async () => {
    const request = new Request('https://example.com/api/data', {
      // Simula una richiesta dall'Iran
      cf: { country: 'IR' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.status).toBe(451);

    const body = await response.json() as { error: string };
    expect(body.error).toContain('not available');
  });

  it('should add GDPR header for EU users', async () => {
    const request = new Request('https://example.com/', {
      cf: { country: 'IT', isEUCountry: '1' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.headers.get('X-GDPR-Required')).toBe('1');
  });

  it('should not require GDPR for US users', async () => {
    const request = new Request('https://example.com/', {
      cf: { country: 'US', isEUCountry: '0' } as object,
    } as RequestInit);

    const response = await SELF.fetch(request);
    expect(response.headers.get('X-GDPR-Required')).toBe('0');
  });
});

지리적 위치의 정확성

Cloudflare는 지리적 위치에 매우 정확한 BGP 데이터를 사용합니다. 국가 수준에서는(>99.9%), 도시나 지역 수준에서는 그 비율이 낮습니다. 법적 사용 사례(예: 제재 차단)의 경우 권장됩니다. Cloudflare 지리적 위치와 추가 확인을 결합합니다. (예: 원본 서버의 MaxMind GeoIP) 위치정보만 사용하지 마세요 중요한 법적 의무에 대한 유일한 준수 수단으로 활용됩니다.

결론 및 다음 단계

Cloudflare Workers를 통한 엣지에서의 지리적 라우팅은 근본적으로 단순화됩니다. 글로벌 시장을 위한 맞춤화: 지리적 위치 데이터베이스가 필요하지 않습니다. 원본 서버를 변경할 필요가 없으며 더 이상 로직이 실행됩니다. 최소한의 대기 시간으로 사용자에게 가까이 다가갑니다.

표시된 패턴 — 지오펜싱, 현지화된 가격 책정, GDPR 준수, 다중 지역 라우팅 - 대부분의 요구 사항을 충족합니다. 국제출원의 경우. 핵심은 이미 가지고 있는 데이터를 활용하는 것입니다. 에 존재 request.cf 추가 미들웨어를 추가하는 대신.

시리즈의 다음 기사

  • 제8조: Cloudflare의 캐시 API 및 무효화 전략 작업자: TTL, 재검증 중 오래된 기능을 사용하여 분산 캐싱 계층을 구축하는 방법 전반적인 성능을 최적화하기 위한 키별 무효화.
  • 제9조: 현지 작업자 테스트 — Miniflare, Vitest Wrangler Dev: 지역 기반 논리 및 바인딩 테스트를 위한 완전한 워크플로 Cloudflare에 배포하지 않고.
  • 제10조: 엣지의 풀스택 아키텍처 — 사례 연구 제로에서 프로덕션까지: 시리즈의 모든 개념을 REST API로 통합 완료.