Waarom geografische routering aan de rand thuishoort

Het personaliseren van inhoud op basis van geografische locatie is een van de meest voorkomende behoeften in wereldwijde webapplicaties: prijzen weergeven in de lokale valuta, voldoen aan landspecifieke regelgeving (GDPR in Europa, CCPA in Californië), inhoud in bepaalde rechtsgebieden blokkeren en omleiden richting regionale domeinen.

Historisch gezien werd dit afgehandeld met geolocatiedatabases op de server (MaxMind GeoLite2) of met handmatig geconfigureerde CDN-regels. Beide benaderingen ze hebben beperkingen: de databaseserver voegt latentie toe, de CDN-regels zijn statisch en moeilijk dynamisch bij te werken.

Met Cloudflare Workers is geolocatie dat wel al beschikbaar in het voorwerp request.cf zonder enige database om bij te houden. Cloudflare bepaalt de locatie op basis van de BGP-topologie van het netwerk, niet bij het opzoeken van IP-adressen, met een nauwkeurigheid op landniveau van meer dan 99,9%.

Wat je gaat leren

  • Woningen beschikbaar in request.cf voor geolocatie
  • Geofencing: blokkeren en omleiden per land
  • Gelokaliseerde prijzen: valuta en btw op basis van regio
  • AVG-naleving: automatische toestemming voor cookies voor EU-gebruikers
  • Routering tussen meerdere regio's met aangepaste headers
  • Geogebaseerde logica testen zonder implementatie

Het voorwerp request.cf van Cloudflare

Elk verzoek aan een Cloudflare-werknemer bevat het onderwerp cf met Geografische en netwerkmetadata bepaald door Cloudflare in realtime:

// 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: blokkeren per land

Geo-fencing is het patroon van het blokkeren of omleiden van inhoud naar specifieke landen. De meest voorkomende gebruiksscenario’s zijn: blokkeren voor internationale sancties, inhoud met territoriale licenties (streaming, media) en markten nog niet open voor bepaalde producten:

// 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 {}

Gelokaliseerde prijzen en valuta's

Het weergeven van prijzen in de lokale valuta van de klant is een best practice van e-commerce die de conversie verhoogt. Met de Werknemer kan dat bepaal de juiste valuta voordat het verzoek zelfs maar bereikt is de oorspronkelijke server:

// 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;
}

AVG-naleving aan de rand

De AVG is van toepassing op alle gebruikers die woonachtig zijn in de Europese Unie, ongeacht waar de server zich bevindt. De meest voorkomende aanpak is om de cookie-toestemmingsbanner aan EU-gebruikers te tonen en niet aan anderen. Met de Worker kan deze logica worden afgehandeld voordat deze wordt weergegeven:

// 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 {}

Routing over meerdere regio's met aangepaste headers

Een geavanceerd patroon is om de Worker te gebruiken als geografische load balancer op intelligente wijze verzoeken doorsturen naar het dichtstbijzijnde datacenter gebaseerd op gemeten latentie en bedrijfsregels:

// 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 {}

Geogebaseerde logische test

Voor het lokaal testen van geografische routering moet het object worden gesimuleerd request.cf. Met wrangler dev het is mogelijk aangepaste CF-waarden injecteren:

# 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');
  });
});

Nauwkeurigheid van geolocatie

Cloudflare gebruikt BGP-gegevens voor geolocatie, wat zeer nauwkeurig is op landelijk niveau (>99,9%), maar minder op stad- of regioniveau. Voor legale gebruiksgevallen (bijvoorbeeld blokkeren voor sancties) is dit raadzaam combineer Cloudflare-geolocatie met aanvullende verificatie (bijvoorbeeld MaxMind GeoIP op de oorspronkelijke server). Gebruik geolocatie niet alleen edge als de enige nalevingsmaatregel voor kritische wettelijke verplichtingen.

Conclusies en volgende stappen

Geografische routing aan de edge met Cloudflare Workers vereenvoudigt radicaal maatwerk voor mondiale markten: geen geolocatiedatabase nodig, Het is niet nodig om de oorspronkelijke server te wijzigen en de logica werkt niet meer dicht bij de gebruiker met minimale latentie.

De getoonde patronen – geofencing, gelokaliseerde prijzen, naleving van de AVG, routering in meerdere regio's — dekt de meeste behoeften van een internationale aanvraag. De sleutel is om gebruik te maken van de gegevens die u al heeft aanwezig in request.cf in plaats van extra middleware toe te voegen.

Volgende artikelen in de serie

  • Artikel 8: Cache-API en invalidatiestrategieën in Cloudflare Workers: hoe u een gedistribueerde cachinglaag bouwt met TTL, verouderd terwijl u opnieuw valideert en ongeldigverklaring per sleutel om de algehele prestaties te optimaliseren.
  • Artikel 9: Testen van werknemers op lokaal niveau — Miniflare, Vitest en Wrangler Dev: de complete workflow voor het testen van geogebaseerde logica en bindingen zonder implementatie op Cloudflare.
  • Artikel 10: Full-stack-architecturen aan de edge: casestudy Van Zero naar Production: Integratie van alle concepten uit de serie in een REST API compleet.