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

## Workers Cache と `no-store` の方針

Cloudflare Workers 版は、Cache API (`caches.default`) を自前で呼ぶ代わりに、[Workers Cache](https://developers.cloudflare.com/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 キャッシュ](https://www.better-auth.com/docs/concepts/session-management#cookie-cache) (`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` を渡さないため、何もしません
