Routing Geografico all'Edge: Personalizzazione Contenuti e Compliance GDPR
Impara a costruire logica di routing basata su paese, regione e lingua direttamente nei Workers, con esempi per geo-fencing, prezzi localizzati e compliance GDPR senza modificare il server principale.
Perché il Routing Geografico Appartiene all'Edge
Personalizzare i contenuti in base alla posizione geografica è una delle esigenze più comuni nelle applicazioni web globali: mostrare prezzi nella valuta locale, rispettare regolamentazioni specifiche per paese (GDPR in Europa, CCPA in California), bloccare contenuti in determinate giurisdizioni, e redirigere verso domini regionali.
Storicamente questo veniva gestito con geolocation database lato server (MaxMind GeoLite2) o con CDN rules configurate manualmente. Entrambi gli approcci hanno limiti: il database server aggiunge latenza, le CDN rules sono statiche e difficili da aggiornare dinamicamente.
Con Cloudflare Workers, la geolocalizzazione è già disponibile
nell'oggetto request.cf senza alcun database da mantenere.
Cloudflare determina la posizione basandosi sulla topologia BGP di rete,
non su IP lookup, con una precisione a livello di paese superiore al 99.9%.
Cosa Imparerai
- Proprietà disponibili in
request.cfper la geolocalizzazione - Geo-fencing: blocco e redirect per paese
- Prezzi localizzati: valuta e IVA basati sulla regione
- Compliance GDPR: cookie consent automatico per utenti UE
- Routing multi-regione con header personalizzati
- Testing della logica geo-based senza deploy
L'Oggetto request.cf di Cloudflare
Ogni richiesta a un Cloudflare Worker include l'oggetto cf con
metadati geografici e di rete determinati da Cloudflare in tempo reale:
// 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: Blocco per Paese
Il geo-fencing è il pattern di blocco o redirect di contenuti per specifici paesi. I casi d'uso più comuni sono: blocco per sanzioni internazionali, contenuti con licenze territoriali (streaming, media), e mercati non ancora aperti a certi prodotti:
// 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 {}
Prezzi Localizzati e Valute
Mostrare i prezzi nella valuta locale del cliente è una best practice di e-commerce che aumenta il tasso di conversione. Con il Worker, puoi determinare la valuta corretta prima ancora che la richiesta raggiunga il server di 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;
}
Compliance GDPR all'Edge
Il GDPR si applica a tutti gli utenti residenti nell'Unione Europea, indipendentemente da dove si trovi il server. L'approccio più comune è mostrare il cookie consent banner agli utenti EU e non agli altri. Con il Worker, questa logica può essere gestita prima del rendering:
// 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 Multi-Regione con Header Custom
Un pattern avanzato è usare il Worker come load balancer geografico intelligente, instradando le richieste verso il datacenter più vicino in base a latenza misurata e regole di business:
// 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 della Logica Geo-Based
Testare il routing geografico localmente richiede di simulare l'oggetto
request.cf. Con wrangler dev è possibile
iniettare valori CF custom:
# 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');
});
});
Accuratezza della Geolocalizzazione
Cloudflare usa dati BGP per la geolocalizzazione, che è molto accurata a livello di paese (>99.9%) ma meno a livello di città o regione. Per casi d'uso legali (es. blocco per sanzioni) è consigliabile combinare la geolocalizzazione Cloudflare con ulteriori verifiche (es. MaxMind GeoIP nel server di origine). Non usare la sola geolocalizzazione edge come unica misura di compliance per obblighi legali critici.
Conclusioni e Prossimi Passi
Il routing geografico all'edge con Cloudflare Workers semplifica radicalmente la personalizzazione per mercati globali: non serve un database di geolocalizzazione, non serve modificare il server di origine, e la logica viene eseguita più vicino all'utente con latenza minima.
I pattern mostrati - geo-fencing, prezzi localizzati, GDPR compliance,
routing multi-regione - coprono la maggior parte delle necessità
di un'applicazione internazionale. La chiave è sfruttare i dati già
presenti in request.cf invece di aggiungere middleware aggiuntivi.
Prossimi Articoli della Serie
- Articolo 8: Cache API e Strategie di Invalidation in Cloudflare Workers: come costruire un layer di caching distribuito con TTL, stale-while-revalidate e invalidation per chiave per ottimizzare le performance globali.
- Articolo 9: Testing di Workers in Local - Miniflare, Vitest e Wrangler Dev: il workflow completo per testare la logica geo-based e i binding senza deploy su Cloudflare.
- Articolo 10: Architetture Full-Stack sull'Edge - Case Study da Zero a Produzione: integrazione di tutti i concetti della serie in un'API REST completa.

Commenti
Caricamento commenti...
Accedi per commentare