De ce rutarea geografică aparține marginii

Personalizarea conținutului în funcție de locația geografică este una dintre cele mai importante cele mai comune nevoi în aplicațiile web globale: afișarea prețurilor în moneda locală, respectă reglementările specifice țării (GDPR în Europa, CCPA în California), blocați conținut în anumite jurisdicții și redirecționați spre domenii regionale.

Din punct de vedere istoric, acest lucru a fost gestionat cu baze de date de geolocalizare pe partea de server (MaxMind GeoLite2) sau cu reguli CDN configurate manual. Ambele abordări au limitări: serverul de baze de date adaugă latență, regulile CDN sunt statice și dificil de actualizat dinamic.

Cu Cloudflare Workers, geolocalizarea este deja disponibile în obiect request.cf fără nicio bază de date de întreținut. Cloudflare determină locația pe baza topologiei BGP a rețelei, nu pe căutarea IP, cu precizie la nivel de țară peste 99,9%.

Ce vei învăța

  • Proprietăți disponibile în request.cf pentru geolocalizare
  • Geo-fencing: blocare și redirecționare în funcție de țară
  • Prețuri localizate: monedă și TVA în funcție de regiune
  • Conformitate GDPR: consimțământ automat pentru cookie-uri pentru utilizatorii din UE
  • Rutare în mai multe regiuni cu anteturi personalizate
  • Testarea logicii bazate pe geometrie fără implementare

Obiectul request.cf de Cloudflare

Fiecare cerere către un lucrător Cloudflare include subiectul cf cu Metadate geografice și de rețea determinate de Cloudflare în timp real:

// 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: blocare în funcție de țară

Geo-fencing este modelul de blocare sau redirecționare a conținutului către anumite ţări. Cele mai frecvente cazuri de utilizare sunt: blocarea pentru sancțiuni internaționale, conținut cu licențe teritoriale (streaming, media) și piețe care nu sunt încă deschis pentru anumite produse:

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

Prețuri și valute localizate

Afișarea prețurilor în moneda locală a clientului este cea mai bună practică a comerțului electronic care crește rata de conversie. Cu Muncitorul, poți determinați moneda corectă înainte ca cererea să ajungă serverul de origine:

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

Conformitatea GDPR la limită

GDPR se aplică tuturor utilizatorilor rezidenți în Uniunea Europeană, indiferent de locul unde se află serverul. Cea mai comună abordare este de a afișa bannerul de consimțământ pentru cookie-uri utilizatorilor din UE și nu altora. Cu Worker, această logică poate fi gestionată înainte de randare:

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

Rutare în mai multe regiuni cu anteturi personalizate

Un model avansat este utilizarea Worker ca echilibrator de încărcare geografică în mod inteligent, direcționând cererile către cel mai apropiat centru de date pe baza latenței măsurate și a regulilor de afaceri:

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

Test de logică bazată pe geometrie

Testarea rutării geografice la nivel local necesită simularea obiectului request.cf. Cu wrangler dev este posibil injectați valori CF personalizate:

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

Precizia geolocalizării

Cloudflare folosește date BGP pentru geolocalizare, care este foarte precisă la nivel de țară (>99,9%) dar mai puțin la nivel de oraș sau regiune. Pentru cazurile de utilizare legală (de exemplu, blocarea pentru sancțiuni) este recomandabil combina geolocalizarea Cloudflare cu verificarea suplimentară (de exemplu, MaxMind GeoIP pe serverul de origine). Nu folosiți singur geolocalizarea edge ca singura măsură de conformitate pentru obligațiile legale critice.

Concluzii și pașii următori

Rutarea geografică la margine cu Cloudflare Workers simplifică radical personalizare pentru piețele globale: nu este necesară o bază de date de geolocalizare, Nu este nevoie să schimbați serverul de origine și logica mai rulează aproape de utilizator cu latență minimă.

Modelele afișate — geo-fencing, prețuri localizate, conformitate GDPR, rutare în mai multe regiuni — acoperă majoritatea nevoilor a unei cereri internaționale. Cheia este să folosiți datele pe care le aveți deja prezent în request.cf în loc să adăugați middleware suplimentar.

Următoarele articole din serie

  • Articolul 8: API-ul cache și strategiile de invalidare în Cloudflare Lucrători: Cum se construiește un strat de cache distribuit cu TTL, stale-while-revalidate și invalidarea per-cheie pentru a optimiza performanța generală.
  • Articolul 9: Testarea lucrătorilor în local — Miniflare, Vitest și Wrangler Dev: fluxul de lucru complet pentru testarea logicii și a legăturilor bazate pe geometrie fără implementare pe Cloudflare.
  • Articolul 10: Arhitecturi Full-Stack la margine — Studiu de caz De la zero la producție: integrarea tuturor conceptelor din serie într-un API REST completă.