公開 API レスポンスをキャッシュする

CloudflareBeginner
オンラインで実践に進む

はじめに

公開サポートカタログには、同じ言語とカテゴリに対するリクエストが繰り返し届きます。レスポンスを再利用すると重複処理を減らせますが、キャッシュに顧客ごとのデータを混在させたり、エラーを公開コンテンツとしてキャッシュしたりしてはいけません。この実験では、キャッシュされていない生成処理を確認し、明示的なキャッシュポリシーを追加し、有効期限と対象を限定した無効化をテストした後、デプロイして動作範囲を確認します。

この独立した実験は、Node.js 22.22.0、プロジェクトローカルの Wrangler 4.131.1、分離された評価に使用する Miniflare 4.20260730.0、および合成レスポンス用の fixture が用意された /home/labex/project/public-cache から開始します。これまでに学習した認証、デプロイ、シークレット操作の手順を、自分の学習用アカウントで使用してください。以前の VM、リソース、購入済みドメイン、データベース、有料アップグレードは必要ありません。リクエストは通常のアカウント使用量にカウントされます。

ターミナルを 1 つ開いたままにしてください。カタログデータと認証情報はすべて合成データです。Cache API の内容はリクエストを処理する場所ごとにローカルに保存されます。グローバルネットワーク全体で複製されるキャッシュではありません。最後に Worker を削除し、ローカルシークレットを削除して、ログアウトします。

新しい公開カタログレスポンスを確認する

このステップでは、用意された合成カタログを確認し、キャッシュされていない動作を把握します。fixture はレスポンスごとに新しい UUID を生成するため、時間を推測したりデータベースを使ったりせずに、再利用の有無を確認できます。

cd /home/labex/project/public-cache
node --version
npx wrangler --version
cat src/catalog.js

Node.js v22.22.0 と Wrangler 4.131.1 が表示されることを確認します。セットアップではプロジェクトの依存関係を正確なバージョンでインストールしています。ロックファイルを使って既存のインストールを再現する場合は、npm ci を使用します。評価ランタイムでも、互換性日付に対応する Miniflare 4.20260730.0 が使用されます。fixture の内容は言語、カテゴリ、合成顧客によって変化します。また、X-Demo-Failure: 1 によって 503 をシミュレートできます。これらはテスト用の入力であり、実際の本人確認用認証情報ではありません。

WORKER_NAME="labex-cache-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<CONFIG
{
  "name": "$WORKER_NAME",
  "main": "src/index.js",
  "compatibility_date": "2026-07-30",
  "workers_dev": true,
  "preview_urls": false
}
CONFIG
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
    }
    return deliver(catalog(request, language, category), 'BYPASS');
  }
};
JS
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

リクエストを送る前に、起動が完了するまで待ちます。起動中の場合は cat dev.log をもう一度実行してください。シェル変数を利用できるように、このターミナルは開いたままにします。

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

どちらのリクエストも 200 を返し、audience は public になります。また、generation の UUID はリクエストごとに異なります。X-Lab-Cache: BYPASS は、このハンドラーがキャッシュの検索も書き込みも行っていないことを示します。クライアント向けの Cache-Control: no-store によって、ブラウザーやクライアント側のキャッシュを実験対象から除外しています。ベースラインを置き換える前に、検証を実行してください。

適格な公開レスポンスだけをキャッシュする

このステップでは、Cache API による検索と保存を追加します。まず、jobs で表示される現在の開発ジョブを停止します。以下の例ではジョブ番号 1 を想定しています。

jobs
kill %1
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    const keyUrl = new URL('/api/catalog', url.origin);
    keyUrl.searchParams.set('category', category);
    keyUrl.searchParams.set('lang', language);
    const key = new Request(keyUrl, {method: 'GET'});
    const cache = caches.default;
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
    }
    // Decide eligibility before lookup: a warm public entry must not mask private work or errors.
    const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
      .some(name => request.headers.has(name));
    if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
    const cached = await cache.match(key);
    if (cached) return deliver(cached, 'HIT');
    const response = catalog(request, language, category);
    if (response.status !== 200 || response.headers.has('Set-Cookie')) {
      return deliver(response, 'BYPASS');
    }
    const stored = response.clone();
    stored.headers.set('Cache-Control', 'public, max-age=10');
    // Await completion here so the next request can observe the write.
    await cache.put(key, stored);
    return deliver(response, 'MISS');
  }
};
JS

キーには現在のオリジンと、固定されたルート、カテゴリ、言語が使用されます。クエリパラメータの順序は正規化されますが、コンテンツを決める 2 つの要素はそれぞれ別の値として扱われます。未知のパラメータや同じ要素の重複は、キーの意味を暗黙に変えないように拒否します。

適格性はキャッシュを検索する前に確認します。Authorization、Cookie、合成顧客ヘッダーがあるリクエストは、公開用のエントリが存在していてもキャッシュをバイパスします。エラー用 fixture も検索をバイパスするため、キャッシュ済みの成功レスポンスでエラーを隠すことがありません。保存するのは、Set-Cookie がなく、ステータスが成功を示すレスポンスだけです。レスポンスボディはストリームなので、保存用に複製します。保存したコピーには 10 秒の TTL を設定し、書き込みが完了するまで待機します。返却するレスポンスには no-store を維持しますが、内部の Cache API エントリには独自のキャッシュポリシーがあります。

npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

リクエストを送る前に、起動が完了するまで待ちます。起動中の場合は cat dev.log をもう一度実行してください。シェル変数を利用できるように、このターミナルは開いたままにします。

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?category=network&lang=en"
curl -i "http://127.0.0.1:8080/api/catalog?lang=fr&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=printer"

最初の 2 つのリクエストは 10 秒以内に実行します。最初のキャッシュされていないレスポンスには MISS、繰り返したリクエストには HIT が表示され、同じ generation が保持されます。クエリパラメータの順序を入れ替えてもキーは変わりません。フランス語版とプリンター版では、指定した各要素がレスポンスに反映され、互いに独立したエントリが使われます。読み取り中に TTL が切れた場合は、ペアのリクエストをすぐに繰り返してください。キャッシュ内容が永久に残るとは考えないでください。

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Cookie: demo=synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

顧客情報や本人確認情報を含むリクエストは BYPASS になり、別の顧客の結果が返ることはありません。シミュレートしたエラーは、公開データがキャッシュ済みでも 503 BYPASS になります。その後の公開リクエストでは、エラーではなく公開データが返ります。ローカルサーバーを実行した状態で検証してください。この検証は分離されたローカルランタイムでハンドラーを実行するものであり、クラウド上のキャッシュは変更しません。

ローカルキャッシュエントリを期限切れにして無効化する

このステップでは、同じ正規化キーを使って、認証付きの無効化操作を追加します。これはローカルデータセンターでの削除であり、グローバルなパージではありません。編集する前に、実際に動作している開発ジョブを停止します。

jobs
kill %1
umask 077
PURGE_TOKEN=$(openssl rand -hex 24)
printf 'PURGE_TOKEN=%s\n' "$PURGE_TOKEN" > .dev.vars
cat .gitignore

合成シークレットを Git、公開設定、URL、ログに含めないでください。このシークレットは、この実験の DELETE 操作を保護するためのものであり、Cloudflare API トークンではありません。

cat > src/index.js <<'JS'
import {catalog} from './catalog.js';

function deliver(response, cacheStatus) {
  const headers = new Headers(response.headers);
  headers.set('X-Lab-Cache', cacheStatus);
  // This lab caches inside the Worker, not in the caller's browser.
  headers.set('Cache-Control', 'no-store');
  return new Response(response.body, {status: response.status, headers});
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/health' && request.method === 'GET') {
      return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
    }
    if (url.pathname !== '/api/catalog') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const language = url.searchParams.get('lang') || 'en';
    const category = url.searchParams.get('category') || 'network';
    if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
        [...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
        url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
      return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
    }
    const keyUrl = new URL('/api/catalog', url.origin);
    keyUrl.searchParams.set('category', category);
    keyUrl.searchParams.set('lang', language);
    const key = new Request(keyUrl, {method: 'GET'});
    const cache = caches.default;
    if (request.method === 'DELETE') {
      if (!env.PURGE_TOKEN) return Response.json({error: 'purge_unconfigured'}, {status: 503});
      if (request.headers.get('Authorization') !== `Bearer ${env.PURGE_TOKEN}`) {
        return Response.json({error: 'unauthorized'}, {status: 401, headers: {'Cache-Control': 'no-store'}});
      }
      const invalidated = await cache.delete(key);
      return Response.json({invalidated, scope: 'this-location'}, {
        headers: {'Cache-Control': 'no-store', 'X-Lab-Cache': 'BYPASS'}
      });
    }
    if (request.method !== 'GET') {
      return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET, DELETE'}});
    }
    // Decide eligibility before lookup: a warm public entry must not mask private work or errors.
    const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
      .some(name => request.headers.has(name));
    if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
    const cached = await cache.match(key);
    if (cached) return deliver(cached, 'HIT');
    const response = catalog(request, language, category);
    if (response.status !== 200 || response.headers.has('Set-Cookie')) {
      return deliver(response, 'BYPASS');
    }
    const stored = response.clone();
    stored.headers.set('Cache-Control', 'public, max-age=10');
    // Await completion here so the next request can observe the write.
    await cache.put(key, stored);
    return deliver(response, 'MISS');
  }
};
JS

DELETE は cache.delete を呼び出す前に認証情報を検証します。使用するキーは、検索や保存で使用した GET キーと同じです。返される Boolean 値は、この場所にエントリが存在していたかどうかを示します。認証されていない削除では、エントリはそのまま残ります。別の場所へのリクエストでは、その場所にある独自のエントリが返される場合があります。

npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log

リクエストを送る前に、起動が完了するまで待ちます。起動中の場合は cat dev.log をもう一度実行してください。シェル変数を利用できるように、このターミナルは開いたままにします。

curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer $PURGE_TOKEN"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

認証されていない DELETE は 401 を返します。有効な DELETE は scope: this-location を返し、まだ有効なエントリがあれば通常は invalidated: true になります。短い TTL がすでに切れている場合は false になることもあります。次の GET では、MISS と新しい generation が返ります。true を確認するには、認証付き DELETE の直前に GET を実行してください。

sleep 11
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"

11 秒後に新しい MISS が返れば、明示的に削除しなくても有効期限が切れたことを確認できます。サーバーを実行した状態で検証してください。分離されたランタイムによって、再利用、要素ごとの分離、非公開・エラーレスポンスの除外、拒否された削除、対象を限定した削除、別キーの保持、有効期限を確認できます。これらの制御されたローカル検証により、グローバルなキャッシュ状態を仮定せずに、再現可能な証拠を得られます。

Cache API のドキュメントでは、データセンター単位のスコープ、レスポンスヘッダーの動作、cache.delete について説明しています。Cache API と、Worker の実行をスキップするプラットフォームのキャッシュは別の仕組みです。

デプロイしてキャッシュの範囲を確認する

このステップでは、完成したハンドラーを学習用アカウントにデプロイします。まず実際に動作しているローカルジョブを停止し、この新しい VM を認証してから、アカウント情報を確認します。

jobs
kill %1
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_tail:read

表示されたデバイス用リンクとコードを、ログイン済みのブラウザーで使用します。変更されていない権限と Background Access を確認し、学習用アカウントを選択してください。ターミナルで成功が表示されるまで待ちます。

npx wrangler whoami --json

意図したアカウント名であることを確認します。以下の YOUR_ACCOUNT_ID を実際の ID に置き換えます。Worker の一意な名前はそのまま使用してください。

cat > wrangler.jsonc <<CONFIG
{
  "name": "$WORKER_NAME",
  "main": "src/index.js",
  "compatibility_date": "2026-07-30",
  "workers_dev": true,
  "preview_urls": false,
  "account_id": "YOUR_ACCOUNT_ID"
}
CONFIG
npx wrangler deploy
npx wrangler secret bulk .dev.vars
npx wrangler secret list

ローカルのシークレットファイルは deploy ではアップロードされません。明示的に実行する bulk コマンドによって、PURGE_TOKEN が secret_text として作成されます。デプロイ後、短い反映時間を待ちます。Wrangler に表示された実際の公開 URL を以下に設定してください。

APP_URL="https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev"
curl -i "$APP_URL/api/catalog?lang=en&category=network"
curl -i "$APP_URL/api/catalog?category=network&lang=en"
curl -i "$APP_URL/api/catalog?lang=fr&category=network"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"

公開レスポンスには、指定した言語とカテゴリ、および public audience が含まれている必要があります。同じ場所で TTL 内に繰り返したリクエストでは HIT になり、同じ generation が保持される場合があります。一方、別の場所からのリクエストや有効期限切れによって MISS になることもあります。2 回のリクエストだけで、キャッシュ内容がグローバルに共有されているとは判断しないでください。非公開リクエストは必ずバイパスし、エラーは 503 BYPASS になる必要があります。

同じ Dashboard アカウントで、Compute → Workers & Pages を開き、対象の Worker と workers.dev URL が正確であることを確認します。検証を使って、所有者、デプロイされたシークレットのバインディング、レスポンスの境界を確認してください。この検証ではクラウド上の無効化は行いません。無効化の動作はローカルでテスト済みです。cache.delete はグローバルなパージ機能ではありません。デプロイの反映に時間がかかっている場合は、少し待ってレスポンス確認を繰り返してください。結果が継続的に一致しない場合は、問題を調査し、受け入れたことにしないでください。

使い捨て Worker を削除する

このステップでは、認証された状態で、この実験のデプロイを削除します。一意な名前とアカウントを確認し、この Worker だけを削除してください。

cat wrangler.jsonc
npx wrangler delete

名前が一致する確認プロンプトが表示されたら、y キーを 1 回だけ押します。Wrangler 4.131.1 では、削除後に既知のレガシー Workers Sites KV 認証診断が表示される場合があります。権限を広げたり、そのエラーを削除成功の証拠とみなしたりしないでください。Dashboard を更新し、検証を実行します。認証済みのインベントリに、この正確な Worker が存在しないことを確認してください。学習用アカウントとそのサブドメインは残します。Worker の削除は、すべてのキャッシュエントリがグローバルにパージされたことを意味しません。合成エントリの TTL は 10 秒であり、実行中のアプリケーションは残りません。

ローカルシークレットを削除して切断する

このステップでは、削除を確認した後、ローカルの使い捨て認証情報を削除して VM との接続を解除します。

rm .dev.vars
unset PURGE_TOKEN
npx wrangler logout
npx wrangler whoami --json

明示的に loggedIn: false が表示されることを確認します。認証されていない状態で実行する構造化コマンドは、終了ステータスが 0 以外になる場合があります。検証を実行してから VM を終了します。ブラウザーのログイン状態は残っていてもかまいません。ログアウトや VM の終了によって、クラウド上のデプロイが自動的に削除されることはありません。

まとめ

キャッシュされていないカタログ生成を、明示的な公開レスポンスキャッシュに置き換えました。言語とカテゴリのキーを分離し、検索前に非公開リクエストと失敗したリクエストをバイパスしました。制御されたローカルランタイムで短時間だけ有効なエントリと認証付き無効化をテストし、グローバルに共有されたキャッシュ内容を仮定せずに、デプロイ済みアプリケーションの ID とレスポンスの境界を確認しました。

保存用コピーの TTL と、呼び出し元に返すレスポンスのキャッシュポリシーは、それぞれ異なる目的を持ちます。意図的な適格性判定、完全なキー、観測可能なレスポンス生成番号によって、この違いをテストできるようにしました。VM を切断する前に、使い捨てのデプロイとローカル認証情報を削除しました。