コンテンツにスキップ
箱庭諸島2 TypeScript 版
Esc
↑↓移動↵開く⌘Jプレビュー
このページの内容

キャッシュの仕組み

Workers Cache のエントリポイント構成と既定の no-store 方針、未ログイン・ログイン中のページのキャッシュ、画像の長期キャッシュを説明します。

Workers Cache と no-store の方針

Cloudflare Workers 版は、Cache API (caches.default) を自前で呼ぶ代わりに、Workers Cache (wrangler.jsonc の cache.enabled: true) を使います。応答の Cache-Control に従って Cloudflare 側が自動でキャッシュする機能で、*.workers.dev のデフォルトドメインでも有効です。Workers Cache (cache.enabled: true) を有効にすると静的アセットへのリクエストも Workers のリクエストとして課金対象になります。

Workers Cache は Cache-Control の無い応答もヒューリスティックにキャッシュし、しかも Cookie 付きのリクエストをバイパスしません。そのため、セッションに依存する HTML が他人に配信されないよう、packages/core/src/web/app.tsx の defaultCacheControlMiddleware が すべての応答に既定で Cache-Control: private, no-store を付け、ルートが明示的に Cache-Control を設定している場合だけそちらを優先します。

エントリポイントの構成

Workers Cache のキャッシュキーは エントリポイント + パスとクエリ + ctx.props + Worker のバージョン です。Host・Cookie・Authorization はキーに含まれません。これを踏まえて、Worker を 2 つのエントリポイントに分けています。

Request
  │
  ▼
default (Gateway)                ← キャッシュ無効。毎回実行される
  │  キャッシュしてよいリクエストだけを内側へ渡し、それ以外は DO へ直接転送する
  ▼
ctx.exports.CachedPages({ props }).fetch(req, { cf: { cacheKey } })
  │                              ← キャッシュ有効 (HIT なら CachedPages は実行されない)
  ▼
CachedPages (WorkerEntrypoint)   ← DO へ転送し、応答をキャッシュさせる形に整える
  │
  ▼
HakoniwaGame (DO)
  • wrangler.jsonc の exports で、default はキャッシュ無効、CachedPages だけ有効にしています。Gateway をキャッシュ有効のまま no-store を返すと、毎回キャッシュ層を往復する分だけ遅くなるためです
  • どのリクエストを CachedPages 経由にするか、props・キャッシュキー・ブラウザへ返すヘッダは packages/cloudflare/src/cache-policy.ts の純粋な関数 (planGatewayRequest、toBrowserResponse、toCacheableResponse) で決め、単体テスト (test/cache-policy.test.ts) で確認しています
  • Gateway は CachedPages へ渡すリクエストを組み立て直します。キャッシュキーに現れない入力で DO の応答が変わらないよう、クエリは必要なものだけ残し、ヘッダも必要なものだけ渡します (未ログイン扱いのリクエストには Cookie を渡しません)
  • CachedPages は Set-Cookie を含む応答を private, no-store にして、キャッシュさせません
  • Gateway は CachedPages の応答のうち画像以外を Cache-Control: private, no-store に書き換えてからブラウザへ返します (エッジのキャッシュ用に public にしていても、ブラウザには HTML をキャッシュさせないため)
  • 利用側のエントリが CachedPages を再エクスポートしていない (ctx.exports.CachedPages が無い) 場合は、キャッシュを使わずに DO へ直接転送します

CachedPages 経由にしているのは、OGP 画像と、未ログイン・ログイン中のページ (後述) です。OGP 画像は誰が見ても同じ画像なので props は { v: 1, viewer: "anonymous" } の固定値にし、クエリは turn だけを残します。Gateway は動作確認用に X-Hakoniwa-Route: cached | direct (CachedPages 経由か、DO へ直接転送したか) を付けます。

GET /games/:gameId/islands/:id/ogp.png は、画像の変わりやすさに応じて Cache-Control を出し分けます (routes/islands.tsx の ogpCacheControl)。

状態 Cache-Control
過去のゲーム、終了済みのゲーム public, max-age=31536000, immutable
進行中のゲーム 次のターンまでの秒数 (60〜3600 秒にクランプ)
ゲーム開始前 public, max-age=60

この既定の no-store は Node.js 版でも同じように動きますが、Node.js 版はキャッシュ層を持たないため実質的な影響はありません。

画像の長期キャッシュ

packages/core/public/images/ の地形タイル画像やロゴは、内容の変わらない固定名のファイルです。packages/core/public/_headers で /images/* だけ Cache-Control: public, max-age=31536000, immutable にしています。画像を差し替える場合はファイル名を変えてください。

未ログインのページのキャッシュ

未ログインの GET の /games (ゲーム一覧)・/games/:gameId (トップ)・/games/:gameId/islands/:id (観光) は、DO が描画した HTML をそのまま Workers Cache にキャッシュします。

  • 未ログインの判定: better-auth の Cookie (hako. または __Secure-hako. で始まる名前) を 1 つも持たないリクエストだけを対象にします。Cookie を持つリクエストは (ログイン中のページを誤って未ログインのキャッシュから返さないよう) DO へ直接転送します
  • props: { v: 1, viewer: "anonymous", origin }。origin はリクエストのオリジンです。観光ページの OGP メタタグの絶対 URL にオリジンを使うため (HAKONIWA_BASE_URL 未設定時)、Host を含まないキャッシュキーを補います
  • DO に渡すリクエスト: クエリはすべて落とし、Cookie などのヘッダも渡しません。DO は必ず未ログインとして描画します
  • キャッシュ期間は DO が決める: @hakoniwajs/core の web/cache-hint.ts が、ページの応答に内部ヘッダ X-Hakoniwa-Cache-Control (期間) と X-Hakoniwa-Cache-Tag (タグ) を付けます (buildDeps({ cacheHints: true }) の場合だけ。Node.js 版は付けません)。CachedPages が 200 の応答に限って Cache-Control: public, <期間> と Cache-Tag に変換し、内部ヘッダは消します。ブラウザへ返す Cache-Control は private, no-store のままです
ページの状態 キャッシュ期間
過去のゲーム max-age=31536000, immutable (記帳も更新もできず、内容は変わらない)
終了済みのゲーム (現在のゲーム) のトップ・観光・開発画面 max-age=60, stale-while-revalidate=60 (トップの順位表は資金・食料の最大化で変わる。観光・開発画面は記帳も入りうる)
進行中のゲーム 次のターンまでの秒数 (上限 60 秒) + stale-while-revalidate=60。予定時刻を過ぎていればキャッシュしない
ゲーム開始前 ゲーム開始までの秒数 (上限 60 秒) + stale-while-revalidate=60
ゲーム一覧 max-age=60, stale-while-revalidate=60

タグは game-<gameId>・island-<gameId>-<islandId>・games です。

キャッシュした HTML の表示が古くならないよう、「次のターンまであと N 分」のような リクエスト時刻に依存する表示は HTML に埋め込みません。トップページの残り時間は、予定時刻 (unix 秒) を data-remaining-until 属性に置き、packages/core/public/remaining.js がブラウザの現在時刻で計算して表示します (表記は app/format.ts の formatRemaining と同じ)。JavaScript が無効な場合は、予定時刻 (絶対時刻) だけが表示されます。

ログイン中のページのキャッシュ

ログイン中の GET の /games・/games/:gameId・/games/:gameId/islands/:id・/games/:gameId/my-island (開発画面) も、セッションごとに キャッシュします。キャッシュ期間・タグは未ログインのページと同じです。

エッジでのセッションの検証

いまのセッションを確かめられるのは DO だけですが、キャッシュのたびに DO に問い合わせては意味がありません。そこで better-auth の Cookie キャッシュ (session.cookieCache) を有効にし (Cloudflare 版だけ。buildDeps({ sessionCookieCache: true }))、セッション情報を署名付き Cookie hako.session_data (HTTPS で __Secure- が付く場合あり) にも入れています。Gateway はこれを DO に問い合わせずに検証します (@hakoniwajs/core の verifySessionCookieCache)。

  • 署名 (HMAC-SHA256)・Cookie キャッシュの有効期限・セッション自体の有効期限の検証は、better-auth 自身の getCookieCache に任せます。Cookie 名も better-auth の getCookies で求めます (authCookieNames)
  • Cookie キャッシュの中のセッショントークンが、セッショントークンの Cookie (hako.session_token) と一致することも確かめます。一致しなければ DO 側の better-auth は DB を引くため、エッジと DO で別のセッションになりうるからです
  • better-auth の Cookie が同じ名前で複数ある場合などは、検証もキャッシュもしません (Gateway の better-auth の parseCookies は同じ名前を後勝ち、DO の better-auth が使う better-call は先勝ちで読むため、エッジと DO で別の値を読まないようにします)
  • 署名の鍵 (auth secret) は、HAKONIWA_AUTH_SECRET があればそれ、無ければ DO の RPC (HakoniwaGame.authSecret()) で取得し、isolate 内で 10 分間メモ化します
  • 検証できない場合 (Cookie キャッシュが無い・期限切れ・期限まで 10 秒未満・改ざん・secret を取得できない) は、キャッシュを使わずに DO へ直接転送 します
  • Cookie キャッシュの有効期間は 5 分 (SESSION_COOKIE_CACHE_MAX_AGE_SEC) です。期限が切れた後の最初のリクエストは DO へ直接転送され、DO が DB のセッションを確かめて Cookie キャッシュを作り直します (session ミドルウェアが GET/HEAD の応答に better-auth の Set-Cookie を付けます)

DO の session ミドルウェアも、GET/HEAD では Cookie キャッシュを使います (エッジで検証したセッションと一致させるため)。管理画面 (/admin*)・アカウント設定 (/account*) と、POST などの状態を変えるリクエストでは、Cookie キャッシュを使わずに DB のセッションを確かめます。そのため、ログアウトや他の端末でのセッション削除がキャッシュされたページに反映されるまで、最大で 5 分かかります (管理画面・操作には即座に反映されます)。

props と自分の操作の反映

props は { v: 1, viewer: "session", sessionId, rev, origin } です。

  • sessionId: ページに埋め込む CSRF トークンが HMAC(secret, sessionId) なので、ユーザー単位ではなくセッション単位で分けます
  • rev: Cookie hako_rev の値です。DO は状態を変えるリクエスト (GET/HEAD 以外) の応答に、単調増加する世代番号 (Date.now() 由来の 36 進数) を Set-Cookie: hako_rev=... で付けます。計画の登録やコメントの変更の直後の GET は新しいキャッシュキーになり、必ず最新の内容が表示されます。purge は 5 回/分しか使えないため、自分の操作による変更は purge ではなくキーを変えることで反映します
  • hako_rev は利用者が書き換えられますが、sessionId はエッジで検証した値なので、書き換えても自分のセッションのキャッシュが外れるだけです

DO には better-auth の Cookie だけを渡し、クエリはトップの notice=no_island だけを残します。他のプレイヤーの操作やターン進行は、未ログインのページと同じく最大 60 秒 (+ stale-while-revalidate) で反映されます。

キャッシュしないもの

管理画面 (/admin*)・アカウント設定 (/account*)・ログイン (/login、/auth/*、/api/auth/*)・/・GET/HEAD 以外のリクエストは、Gateway から DO へそのまま転送します。Set-Cookie を含む応答は CachedPages がキャッシュさせません。

purge (キャッシュの削除)

キャッシュした全ページの内容が変わる稀なイベントでは、CachedPages のキャッシュを丸ごと消します (purgeEverything)。

イベント purge する場所
サイト設定の変更、ゲームの開始・終了、データの削除、最終更新時刻・1 ターンの長さ・最終ターン数の変更、手動でのターン進行、資金・食料の最大化 管理画面のルート (packages/core/src/web/routes/admin.tsx) が、操作の成功後に WebDeps.cachePurger.purgeAll() を呼ぶ
バックアップの復元 BookmarkBackupStore.restore (packages/cloudflare/src/backup.ts) が、復元の予約と同じ blockConcurrencyWhile の中で、予約の前に呼ぶ。失敗したときは予約も再起動もせず、管理画面に中止を表示する
ターンの自動進行 (Cron やページ表示に伴う進行) 不要 (キャッシュ期間を次のターンまでにしているため)
デプロイ 不要 (Worker のバージョンごとにキャッシュが分かれるため)
管理者の追加・削除、ログイン方法の切り替え、バックアップの作成・削除 不要 (キャッシュしているページの内容が変わらない)
  • purge は、それを呼んだエントリポイントのキャッシュにしか効きません。そのため DO は ctx.exports.CachedPages.purgeEverything() (RPC) で、キャッシュを持つ CachedPages 自身に ctx.cache.purge({ purgeEverything: true }) を呼ばせます (packages/cloudflare/src/cache-purge.ts)
  • purge の回数には上限 (Free プラン相当: 5 回/分、バケット 25) があるため、自分の操作や他のプレイヤーの操作による変更は purge ではなく、キャッシュ期間とキャッシュキー (rev) で反映します
  • purge に失敗しても (上限に達した場合など)、サイト設定の変更などの管理操作は成功のままにします。失敗はログに残し、settings 表の cache.purge_pending に印を置いて、Cron (checkTurn、15 分ごと) でやり直します。終了済みの現在のゲームのページは 60 秒で切れるため、やり直しまでのあいだも 1 年は残りません
  • バックアップの復元は、purge に失敗したら復元自体を中止します。印は残しません (データは変わっていないため)。purge の API が無い環境では、キャッシュが無いので復元を続行します
  • Node.js 版は cachePurger を渡さないため、何もしません