地理的ルーティングがエッジに属する理由

地理的位置に基づいてコンテンツをパーソナライズすることは、その 1 つです。 グローバル Web アプリケーションで最も一般的なニーズ: に価格を表示する 現地通貨、国固有の規制に準拠 (ヨーロッパの GDPR、 カリフォルニア州の CCPA)、特定の法域でのコンテンツのブロック、およびリダイレクト 地域ドメインに向けて。

従来、これはサーバー側の地理位置情報データベースで処理されていました。 (MaxMind GeoLite2)、または手動で構成された CDN ルールを使用します。どちらのアプローチも これらには制限があります。データベース サーバーにより待ち時間が追加され、CDN ルールは静的です。 動的に更新することは困難です。

Cloudflare Workers では、地理位置情報が すでに利用可能です オブジェクトの中で request.cf データベースを維持する必要はありません。 CloudflareはネットワークのBGPトポロジに基づいて場所を決定します。 IP ルックアップではなく、国レベルの精度が 99.9% 以上です。

何を学ぶか

  • で利用可能なプロパティ request.cf 地理位置情報用
  • ジオフェンシング: 国ごとのブロックとリダイレクト
  • ローカライズされた価格設定: 地域に基づいた通貨と VAT
  • GDPR 準拠: EU ユーザー向けの自動 Cookie 同意
  • カスタムヘッダーを使用したマルチリージョンルーティング
  • 導入せずに地理ベースのロジックをテストする

オブジェクト request.cf アーティスト: クラウドフレア

Cloudflare Workerへの各リクエストには件名が含まれます 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;
}

ジオフェンシング: 国ごとのブロック

ジオフェンシングは、コンテンツをブロックまたは特定のコンテンツにリダイレクトするパターンです。 国々。最も一般的な使用例は次のとおりです: 国際制裁のためのブロック、 地域ライセンスのあるコンテンツ (ストリーミング、メディア)、およびまだ市場が確立されていない 特定の製品にオープン:

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

ローカライズされた価格と通貨

価格を顧客の現地通貨で表示することがベスト プラクティスです コンバージョン率を高める電子商取引。 Worker を使用すると、次のことができます リクエストが届く前に正しい通貨を決定する オリジンサーバー:

// 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 は欧州連合に居住するすべてのユーザーに適用されます。 サーバーの場所に関係なく。最も一般的なアプローチ Cookie 同意バナーを EU ユーザーに表示し、他のユーザーには表示しないことです。 Worker を使用すると、レンダリング前にこのロジックを処理できます。

// 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 に統合 完了しました。