From ce00537ec0796f849c98d38bdf47a7a1d0f49b29 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 14 Jun 2026 13:42:24 +0000 Subject: [PATCH 1/4] perf(problems): cache anonymous /problems responses at the CDN edge Anonymous responses contain no per-user answer state and are identical for all visitors, so set Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600 for anonymous requests. Logged-in and degraded (vote stats failure) responses are deliberately excluded. Co-Authored-By: Claude Sonnet 4.6 --- .../problems-anonymous-cdn-cache/plan.md | 128 ++++++++++++++++++ e2e/problems_cache.spec.ts | 20 +++ src/routes/problems/+page.server.ts | 14 +- src/routes/problems/page_server.test.ts | 94 +++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md create mode 100644 e2e/problems_cache.spec.ts create mode 100644 src/routes/problems/page_server.test.ts diff --git a/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md b/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md new file mode 100644 index 000000000..1710bedde --- /dev/null +++ b/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md @@ -0,0 +1,128 @@ +# Phase 3:problems 匿名レスポンスの CDN キャッシュ + +## Context(背景・目的) + +Vercel の Function Duration / Fast Origin Transfer が増加(親plan [docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) 参照)。 +`/problems` 一覧は **匿名アクセス時はレスポンスが全員同一**(個人の解答状態を含まない)なので、CDN(Vercel Edge)にキャッシュさせれば bot/クローラー/未ログイン閲覧で**関数自体が起動しなくなり** Duration と Transfer を同時に削減できる。 + +ログイン時は解答状況がユーザー個別 → **絶対にキャッシュさせない**(漏洩リスク)。本 Phase は「匿名時のみ `Cache-Control` を付与」する1ファイル変更。 + +対象: [src/routes/problems/+page.server.ts](../../../../src/routes/problems/+page.server.ts) + +## Cache-Control ディレクティブの意味(MDN準拠) + +設定値: `public, max-age=0, s-maxage=300, stale-while-revalidate=600` + +| ディレクティブ | 対象 | 単位 | 意味 | +| ---------------------------- | ------------ | ---- | ----------------------------------------------------------------------------------- | +| `public` | 共有(CDN) | — | 共有キャッシュへの保存を明示許可。無いと CDN は個人向け扱いでキャッシュしない | +| `max-age=0` | ブラウザ | 秒 | ブラウザは毎回再検証(=ログイン後に匿名HTMLを再利用させない)。CDN は s-maxage 使用 | +| `s-maxage=300` | **共有のみ** | 秒 | CDN 上で 300秒(5分) fresh。`max-age` を共有キャッシュで上書き | +| `stale-while-revalidate=600` | 共有(CDN) | 秒 | s-maxage 失効後さらに 600秒(10分) は stale を即返ししつつ裏で再取得・更新 | + +タイムライン: 0–300s=CDN即返し(関数ゼロ) / 300–900s=stale即返し+裏で1回更新 / 900s以降=通常再取得。 +→ 実効鮮度は最大15分弱。`max-age=0` を明示する根拠は下記「Vercel 公式挙動」参照。 + +出典: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control + +### Vercel 公式挙動(確認済み 2026-06-14) + +1. **swr の動作**: s-maxage 失効後、CDN は stale をエッジから即返ししつつ非同期でバックグラウンドに関数を呼び再検証する。最初の1回のみ同期、以降はキャッシュ即返し+裏で再検証。swr 最大値は1年。 +2. **🔴 重要: クライアントへのヘッダー削除**: `CDN-Cache-Control` を併用せず `Cache-Control` のみ設定した場合、Vercel は **`s-maxage` と `stale-while-revalidate` をエッジで消費し、ブラウザへ送るヘッダーから削除**する。よってブラウザの devtools には `public, max-age=0` しか見えない(本番/プレビュー)。キャッシュ動作の確認は **`x-vercel-cache`** で行う。ローカル `pnpm dev` は Vercel プロキシを通らないため、設定値がそのまま見える。 +3. **`max-age=0` 明示の根拠**: 「全訪問者で同一のサーバーレンダリングページ」に対する Vercel 公式推奨は `max-age=0, s-maxage=N`。`max-age` 省略時のブラウザ・ヒューリスティックキャッシュ余地を排除し、CDN のみキャッシュ+ブラウザは毎回再検証を確定させる。 +4. **デフォルト値**: ヘッダー未設定時は `public, max-age=0, must-revalidate`(CDN・ブラウザともキャッシュしない)→ ログイン時は何も付けなければ自動的に非キャッシュ。 + +出典: https://vercel.com/docs/caching/cache-control-headers, https://vercel.com/docs/edge-network/caching + +## 実装 + +### 変更1: `load` シグネチャに `setHeaders` を追加し、匿名時のみ付与 + +`load({ locals, url })` を `load({ locals, url, setHeaders })` に変更。 +`session === null`(=匿名)かつ **vote stats の取得が成功した場合のみ** 付与する(degraded 応答をキャッシュ汚染させないため。下記「設計判断」参照)。 + +```typescript +export async function load({ locals, url, setHeaders }) { + const session = await locals.auth.validate(); + // ...既存の params / isAdmin / isLoggedIn ... + + let voteResults = new Map(); + let voteStatsOk = true; + try { + voteResults = await getVoteGradeStatistics(); + } catch (error) { + voteStatsOk = false; + console.error('Failed to load vote statistics:', error); + } + + // Anonymous responses are identical for all users and contain no per-user + // answer state, so they are safe to cache at the CDN. Logged-in responses + // are personalized and must never be shared-cached. + // Skip caching a degraded response (vote stats failed) to avoid pinning a + // broken page at the edge for the full TTL. + if (session === null && voteStatsOk) { + setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + } + + // ...既存の return(tagIds 分岐含む)... +} +``` + +`setHeaders` は **return より前**・分岐の外で1回だけ呼ぶ。これにより `tagIds` あり/なし両方の匿名レスポンスがキャッシュ対象になる(どちらも匿名ではユーザー非依存)。 + +## 設計判断(レビュー指摘事項) + +1. **degraded 応答はキャッシュしない**: 既存の try/catch は vote stats 失敗時に空 Map で描画継続する。これを匿名でキャッシュすると**一時障害の劣化ページが最大15分 CDN に貼り付く**。`voteStatsOk` フラグで成功時のみ付与。 +2. **`tagIds` 分岐も対象**: フィルタ済みビューも匿名ではユーザー非依存なのでキャッシュ可。Vercel はクエリ文字列込みの URL 単位でキャッシュするため、tag の組合せごとにキー分裂しヒット率は下がるが安全性に問題なし。分岐の外で付与するだけで両対応。 +3. **`max-age=0` 明示**: Vercel 公式推奨(同一ページは `max-age=0, s-maxage=N`)。ブラウザは毎回再検証=CDN のみキャッシュ。「Vercel 公式挙動」参照。 +4. **ヘッダー重複なし**: `setHeaders` は同一ヘッダー2回設定でエラーになるが、`+layout.server.ts` は session のみ返し cache-control を設定しないため衝突しない。 +5. **POST action 非対象**: `setHeaders` は SSR(GET) の load でのみ有効。`update` / `voteAbsoluteGrade` action には影響しない。 + +## テスト + +検証対象を「分岐ロジック(関数の挙動)」と「キャッシュ適格性(ワイヤー上の実契約)」に分離する。 +前者はユニット(コロケーション)、後者は HTTP レスポンスを見る必要があるため E2E(`e2e/`)に置く。 +ユニットは `setHeaders` が実際にヘッダーに乗るか(= SvelteKit 本体の挙動)や `set-cookie` 不在を**検証できない**点に注意。 + +### ① ユニット — 分岐ロジック(`src/routes/problems/+page.server.test.ts`) + +`load()` を直接呼び、依存をモックして分岐を検証する。**価値の中心は負のケース**(ログイン時に共有キャッシュさせない=個人情報漏洩防止のセキュリティ不変条件)。 +正のケースは値調整で割れない緩いアサーションにする(完全一致は避ける)。 + +**注意: 本コードベースに route-load のユニットテスト・`setHeaders` 使用例ともに前例なし**(新パターンを敷く)。 + +モック対象: + +- `$features/votes/services/vote_statistics` の `getVoteGradeStatistics`(成功=Map / throw の両系統) +- `$lib/services/task_results`(`getTaskResults` / `getTasksWithTagIds`) +- `locals.auth.validate`(null=匿名 / session オブジェクト=ログイン) +- `url`(`searchParams.get` が tagIds を返す/返さない)/`setHeaders` は `vi.fn()` スパイ + +テスト名と検証ケース: + +- `does not set cache-control for logged-in users` → ログイン時 `setHeaders` が**呼ばれない**(最重要・セキュリティ) +- `does not cache a degraded response when vote stats fail` → 匿名 + vote stats throw → `setHeaders` が**呼ばれない** +- `sets a public shared-cache header for anonymous users` → 匿名 + 成功 → `setHeaders` が1回、引数の `cache-control` が `public` と `s-maxage=300` を**含む**こと(緩いアサーション=完全一致しない) + +### ② E2E — キャッシュ適格性(`e2e/` に1本) + +匿名で dev server に GET し、**ユニットでは見えない実契約**を確認する。 + +テスト名: `anonymous /problems response is cache-eligible (cache-control set, no set-cookie)` + +- レスポンスに `cache-control` が付く(ローカルは Vercel 非経由のため `s-maxage` まで見える) +- **`set-cookie` ヘッダーが無い**(キャッシュ適格の本当の条件。ユニットでは auth モックにより検証不能) +- (任意)ログイン状態の同ページでは `cache-control` が**付かない**ことも併せて確認 + +## 検証(手動・デプロイ後) + +1. `pnpm test:unit` でテスト通過。 +2. **ローカル(`pnpm dev`、Vercel プロキシ非経由)**で匿名リクエストのヘッダーを確認: + - `cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=600` がそのまま出る + - **`set-cookie` が出ない**(出ると Vercel はキャッシュしない。クリーンな匿名リクエストで確認。古い無効 session cookie 保持時のみ Lucia がクリア用 Set-Cookie を出す稀な edge は許容=その1リクエストだけ非キャッシュ) +3. ログイン状態で同ページを開き、`cache-control` が**付かない**(=デフォルトの非キャッシュ)ことを確認。 +4. **本番/プレビュー(Vercel 経由)**では `s-maxage`・`stale-while-revalidate` はエッジで削除されブラウザに見えない(公式挙動2)。キャッシュ動作は **`x-vercel-cache`**(`MISS`→`HIT`→失効後 `STALE`)で確認。`pragma: no-cache` 付きで同期再検証=`REVALIDATED` も確認可。Vercel ダッシュボードで Duration / Fast Origin Transfer を 1〜2週間観測。 + +## 完了後 + +親 plan [docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) の Phase 3 セクションに完了マークと、判明した novel lesson(degraded 応答のキャッシュ回避、route-load テストの新規ハーネス)を反映する。 diff --git a/e2e/problems_cache.spec.ts b/e2e/problems_cache.spec.ts new file mode 100644 index 000000000..87b6288c8 --- /dev/null +++ b/e2e/problems_cache.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test.describe('anonymous /problems response', () => { + test('is cache-eligible (cache-control set, no set-cookie)', async ({ page }) => { + const response = await page.goto('/problems'); + + if (!response) { + throw new Error('No response received from /problems'); + } + + const headers = response.headers(); + + // Local dev server (pnpm preview) is not behind Vercel edge, so s-maxage is visible. + expect(headers['cache-control']).toContain('public'); + expect(headers['cache-control']).toContain('s-maxage=300'); + + // set-cookie makes a response non-cacheable at the CDN; must be absent for anonymous pages. + expect(headers['set-cookie']).toBeUndefined(); + }); +}); diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index 7c237cca6..c0e1cd811 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -12,7 +12,7 @@ import { voteAbsoluteGradeSchema } from '$features/votes/zod/schema'; import { BAD_REQUEST } from '$lib/constants/http-response-status-codes'; // 一覧表ページは、ログインしていなくても閲覧できるようにする -export async function load({ locals, url }) { +export async function load({ locals, url, setHeaders }) { const session = await locals.auth.validate(); const params = await url.searchParams; @@ -23,12 +23,24 @@ export async function load({ locals, url }) { // Degrade gracefully if vote stats are unavailable — the problems page must remain accessible. let voteResults = new Map(); + let voteStatsOk = true; + try { voteResults = await getVoteGradeStatistics(); } catch (error) { + voteStatsOk = false; console.error('Failed to load vote statistics:', error); } + // Anonymous responses are identical for all users and contain no per-user + // answer state, so they are safe to cache at the CDN. Logged-in responses + // are personalized and must never be shared-cached. + // Skip caching a degraded response (vote stats failed) to avoid pinning a + // broken page at the edge for the full TTL. + if (session === null && voteStatsOk) { + setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + } + if (tagIds != null) { return { taskResults: (await task_crud.getTasksWithTagIds( diff --git a/src/routes/problems/page_server.test.ts b/src/routes/problems/page_server.test.ts new file mode 100644 index 000000000..236552a89 --- /dev/null +++ b/src/routes/problems/page_server.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { Roles } from '$lib/types/user'; + +vi.mock('$features/votes/services/vote_statistics', () => ({ + getVoteGradeStatistics: vi.fn(), +})); + +vi.mock('$lib/services/task_results', () => ({ + getTaskResults: vi.fn(), + getTasksWithTagIds: vi.fn(), +})); + +import * as voteStatsModule from '$features/votes/services/vote_statistics'; +import * as taskCrud from '$lib/services/task_results'; +import { load } from './+page.server'; + +const mockGetVoteGradeStatistics = vi.mocked(voteStatsModule.getVoteGradeStatistics); +const mockGetTaskResults = vi.mocked(taskCrud.getTaskResults); +const mockGetTasksWithTagIds = vi.mocked(taskCrud.getTasksWithTagIds); + +type MockSession = { user: { userId: string; username: string; role: Roles } } | null; + +const createMockEvent = ({ + session = null, + tagIds = null, +}: { + session?: MockSession; + tagIds?: string | null; +} = {}) => { + const setHeaders = vi.fn(); + const locals = { + auth: { validate: vi.fn().mockResolvedValue(session) }, + user: session + ? { + id: session.user.userId, + name: session.user.username, + role: session.user.role, + atcoder_name: '', + is_validated: false, + } + : undefined, + }; + const url = { searchParams: { get: vi.fn().mockReturnValue(tagIds) } }; + + return { locals, url, setHeaders } as unknown as Parameters[0] & { + setHeaders: ReturnType; + }; +}; + +const LOGGED_IN_SESSION: MockSession = { + user: { userId: 'user-abc123', username: 'testuser', role: Roles.USER }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetTaskResults.mockResolvedValue([]); + mockGetTasksWithTagIds.mockResolvedValue([]); + mockGetVoteGradeStatistics.mockResolvedValue(new Map()); +}); + +describe('load() cache-control behaviour', () => { + describe('sets cache-control', () => { + test('anonymous users get a public shared-cache header when vote stats succeed', async () => { + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).toHaveBeenCalledOnce(); + const headerArg = event.setHeaders.mock.calls[0][0] as Record; + expect(headerArg['cache-control']).toContain('public'); + expect(headerArg['cache-control']).toContain('s-maxage=300'); + }); + }); + + describe('does not set cache-control', () => { + test('logged-in users — personalized response must never be shared-cached', async () => { + const event = createMockEvent({ session: LOGGED_IN_SESSION }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + + test('degraded response when vote stats fail — avoids pinning a broken page at the CDN', async () => { + mockGetVoteGradeStatistics.mockRejectedValue(new Error('DB timeout')); + const event = createMockEvent({ session: null }); + + await load(event); + + expect(event.setHeaders).not.toHaveBeenCalled(); + }); + }); +}); From 3b4310b4895fe368363f9554264e39f2ee981cce Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 14 Jun 2026 22:09:15 +0000 Subject: [PATCH 2/4] test(problems): add logged-in CDN cache exclusion test and rule for route load() unit tests - E2E: add logged-in test asserting s-maxage is absent (personalized responses must not be shared-cached) - Unit: add tagIds branch coverage and fix header key to Cache-Control (canonical case) - .claude/rules/testing.md: document route load() unit test pattern with setHeaders spy Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/testing.md | 15 +++++++++++++++ e2e/problems_cache.spec.ts | 23 ++++++++++++++++++++++- src/routes/problems/+page.server.ts | 2 +- src/routes/problems/page_server.test.ts | 15 +++++++++++++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 39620b2f1..044a12378 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -26,6 +26,8 @@ paths: E2E files: **must** use `.spec.ts` extension (`.test.ts` not detected). +Route unit tests: `src/routes/**/*.test.ts` is included by `vite.config.ts`. **Never use `+` as a filename prefix** — SvelteKit reserves it and `pnpm check` will error. Name route test files `page_server.test.ts`, not `+page.server.test.ts`. + ## Unit Testing Patterns ### Assertions @@ -131,6 +133,19 @@ test('uses override map when entry exists', () => { }); ``` +### Route load() Unit Tests + +`load` in `+page.server.ts` is a plain async function — call it directly with a mock event. Pass `setHeaders` as a `vi.fn()` spy to assert whether and how headers are set. What unit tests **cannot** verify: whether the header actually reaches the wire, or that `Set-Cookie` is absent (auth mocks bypass that) — cover those in E2E. + +```typescript +const createMockEvent = ({ session = null } = {}) => + ({ + locals: { auth: { validate: vi.fn().mockResolvedValue(session) } }, + url: { searchParams: { get: vi.fn().mockReturnValue(null) } }, + setHeaders: vi.fn(), + }) as unknown as Parameters[0] & { setHeaders: ReturnType }; +``` + ### Test Stubs Parameter types **must match** production signature — use domain types (`TaskGrade`), not `string`. Mismatch compiles silently but breaks type safety. diff --git a/e2e/problems_cache.spec.ts b/e2e/problems_cache.spec.ts index 87b6288c8..e93edf709 100644 --- a/e2e/problems_cache.spec.ts +++ b/e2e/problems_cache.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; +import { loginAsUser } from './helpers/auth'; + test.describe('anonymous /problems response', () => { test('is cache-eligible (cache-control set, no set-cookie)', async ({ page }) => { const response = await page.goto('/problems'); @@ -14,7 +16,26 @@ test.describe('anonymous /problems response', () => { expect(headers['cache-control']).toContain('public'); expect(headers['cache-control']).toContain('s-maxage=300'); - // set-cookie makes a response non-cacheable at the CDN; must be absent for anonymous pages. + // set-cookie makes a response ineligible for CDN caching. + // Lucia must not attach a session cookie to anonymous requests. + // If this assertion fails after a Lucia upgrade, verify it does not + // set cookies on unauthenticated requests. expect(headers['set-cookie']).toBeUndefined(); }); }); + +test.describe('logged-in /problems response', () => { + test('is not shared-cached (no CDN cache-control directive)', async ({ page }) => { + await loginAsUser(page); + const response = await page.goto('/problems'); + + if (!response) { + throw new Error('No response received from /problems'); + } + + const headers = response.headers(); + + // Personalized responses must never be shared-cached. + expect(headers['cache-control'] ?? '').not.toContain('s-maxage'); + }); +}); diff --git a/src/routes/problems/+page.server.ts b/src/routes/problems/+page.server.ts index c0e1cd811..901f537dc 100644 --- a/src/routes/problems/+page.server.ts +++ b/src/routes/problems/+page.server.ts @@ -38,7 +38,7 @@ export async function load({ locals, url, setHeaders }) { // Skip caching a degraded response (vote stats failed) to avoid pinning a // broken page at the edge for the full TTL. if (session === null && voteStatsOk) { - setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); + setHeaders({ 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); } if (tagIds != null) { diff --git a/src/routes/problems/page_server.test.ts b/src/routes/problems/page_server.test.ts index 236552a89..a35797766 100644 --- a/src/routes/problems/page_server.test.ts +++ b/src/routes/problems/page_server.test.ts @@ -68,8 +68,19 @@ describe('load() cache-control behaviour', () => { expect(event.setHeaders).toHaveBeenCalledOnce(); const headerArg = event.setHeaders.mock.calls[0][0] as Record; - expect(headerArg['cache-control']).toContain('public'); - expect(headerArg['cache-control']).toContain('s-maxage=300'); + expect(headerArg['Cache-Control']).toContain('public'); + expect(headerArg['Cache-Control']).toContain('s-maxage=300'); + }); + + test('anonymous users with tagIds also get a public shared-cache header', async () => { + const event = createMockEvent({ session: null, tagIds: 'abc,dp' }); + + await load(event); + + expect(event.setHeaders).toHaveBeenCalledOnce(); + const headerArg = event.setHeaders.mock.calls[0][0] as Record; + expect(headerArg['Cache-Control']).toContain('public'); + expect(headerArg['Cache-Control']).toContain('s-maxage=300'); }); }); From 2086b34185a3dd3f0e11bf76c9444edded6b5076 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 14 Jun 2026 22:10:13 +0000 Subject: [PATCH 3/4] docs: remove Phase 3 sub-plan after implementation complete Co-Authored-By: Claude Sonnet 4.6 --- .../problems-anonymous-cdn-cache/plan.md | 128 ------------------ 1 file changed, 128 deletions(-) delete mode 100644 docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md diff --git a/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md b/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md deleted file mode 100644 index 1710bedde..000000000 --- a/docs/dev-notes/2026-06-14/problems-anonymous-cdn-cache/plan.md +++ /dev/null @@ -1,128 +0,0 @@ -# Phase 3:problems 匿名レスポンスの CDN キャッシュ - -## Context(背景・目的) - -Vercel の Function Duration / Fast Origin Transfer が増加(親plan [docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) 参照)。 -`/problems` 一覧は **匿名アクセス時はレスポンスが全員同一**(個人の解答状態を含まない)なので、CDN(Vercel Edge)にキャッシュさせれば bot/クローラー/未ログイン閲覧で**関数自体が起動しなくなり** Duration と Transfer を同時に削減できる。 - -ログイン時は解答状況がユーザー個別 → **絶対にキャッシュさせない**(漏洩リスク)。本 Phase は「匿名時のみ `Cache-Control` を付与」する1ファイル変更。 - -対象: [src/routes/problems/+page.server.ts](../../../../src/routes/problems/+page.server.ts) - -## Cache-Control ディレクティブの意味(MDN準拠) - -設定値: `public, max-age=0, s-maxage=300, stale-while-revalidate=600` - -| ディレクティブ | 対象 | 単位 | 意味 | -| ---------------------------- | ------------ | ---- | ----------------------------------------------------------------------------------- | -| `public` | 共有(CDN) | — | 共有キャッシュへの保存を明示許可。無いと CDN は個人向け扱いでキャッシュしない | -| `max-age=0` | ブラウザ | 秒 | ブラウザは毎回再検証(=ログイン後に匿名HTMLを再利用させない)。CDN は s-maxage 使用 | -| `s-maxage=300` | **共有のみ** | 秒 | CDN 上で 300秒(5分) fresh。`max-age` を共有キャッシュで上書き | -| `stale-while-revalidate=600` | 共有(CDN) | 秒 | s-maxage 失効後さらに 600秒(10分) は stale を即返ししつつ裏で再取得・更新 | - -タイムライン: 0–300s=CDN即返し(関数ゼロ) / 300–900s=stale即返し+裏で1回更新 / 900s以降=通常再取得。 -→ 実効鮮度は最大15分弱。`max-age=0` を明示する根拠は下記「Vercel 公式挙動」参照。 - -出典: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control - -### Vercel 公式挙動(確認済み 2026-06-14) - -1. **swr の動作**: s-maxage 失効後、CDN は stale をエッジから即返ししつつ非同期でバックグラウンドに関数を呼び再検証する。最初の1回のみ同期、以降はキャッシュ即返し+裏で再検証。swr 最大値は1年。 -2. **🔴 重要: クライアントへのヘッダー削除**: `CDN-Cache-Control` を併用せず `Cache-Control` のみ設定した場合、Vercel は **`s-maxage` と `stale-while-revalidate` をエッジで消費し、ブラウザへ送るヘッダーから削除**する。よってブラウザの devtools には `public, max-age=0` しか見えない(本番/プレビュー)。キャッシュ動作の確認は **`x-vercel-cache`** で行う。ローカル `pnpm dev` は Vercel プロキシを通らないため、設定値がそのまま見える。 -3. **`max-age=0` 明示の根拠**: 「全訪問者で同一のサーバーレンダリングページ」に対する Vercel 公式推奨は `max-age=0, s-maxage=N`。`max-age` 省略時のブラウザ・ヒューリスティックキャッシュ余地を排除し、CDN のみキャッシュ+ブラウザは毎回再検証を確定させる。 -4. **デフォルト値**: ヘッダー未設定時は `public, max-age=0, must-revalidate`(CDN・ブラウザともキャッシュしない)→ ログイン時は何も付けなければ自動的に非キャッシュ。 - -出典: https://vercel.com/docs/caching/cache-control-headers, https://vercel.com/docs/edge-network/caching - -## 実装 - -### 変更1: `load` シグネチャに `setHeaders` を追加し、匿名時のみ付与 - -`load({ locals, url })` を `load({ locals, url, setHeaders })` に変更。 -`session === null`(=匿名)かつ **vote stats の取得が成功した場合のみ** 付与する(degraded 応答をキャッシュ汚染させないため。下記「設計判断」参照)。 - -```typescript -export async function load({ locals, url, setHeaders }) { - const session = await locals.auth.validate(); - // ...既存の params / isAdmin / isLoggedIn ... - - let voteResults = new Map(); - let voteStatsOk = true; - try { - voteResults = await getVoteGradeStatistics(); - } catch (error) { - voteStatsOk = false; - console.error('Failed to load vote statistics:', error); - } - - // Anonymous responses are identical for all users and contain no per-user - // answer state, so they are safe to cache at the CDN. Logged-in responses - // are personalized and must never be shared-cached. - // Skip caching a degraded response (vote stats failed) to avoid pinning a - // broken page at the edge for the full TTL. - if (session === null && voteStatsOk) { - setHeaders({ 'cache-control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=600' }); - } - - // ...既存の return(tagIds 分岐含む)... -} -``` - -`setHeaders` は **return より前**・分岐の外で1回だけ呼ぶ。これにより `tagIds` あり/なし両方の匿名レスポンスがキャッシュ対象になる(どちらも匿名ではユーザー非依存)。 - -## 設計判断(レビュー指摘事項) - -1. **degraded 応答はキャッシュしない**: 既存の try/catch は vote stats 失敗時に空 Map で描画継続する。これを匿名でキャッシュすると**一時障害の劣化ページが最大15分 CDN に貼り付く**。`voteStatsOk` フラグで成功時のみ付与。 -2. **`tagIds` 分岐も対象**: フィルタ済みビューも匿名ではユーザー非依存なのでキャッシュ可。Vercel はクエリ文字列込みの URL 単位でキャッシュするため、tag の組合せごとにキー分裂しヒット率は下がるが安全性に問題なし。分岐の外で付与するだけで両対応。 -3. **`max-age=0` 明示**: Vercel 公式推奨(同一ページは `max-age=0, s-maxage=N`)。ブラウザは毎回再検証=CDN のみキャッシュ。「Vercel 公式挙動」参照。 -4. **ヘッダー重複なし**: `setHeaders` は同一ヘッダー2回設定でエラーになるが、`+layout.server.ts` は session のみ返し cache-control を設定しないため衝突しない。 -5. **POST action 非対象**: `setHeaders` は SSR(GET) の load でのみ有効。`update` / `voteAbsoluteGrade` action には影響しない。 - -## テスト - -検証対象を「分岐ロジック(関数の挙動)」と「キャッシュ適格性(ワイヤー上の実契約)」に分離する。 -前者はユニット(コロケーション)、後者は HTTP レスポンスを見る必要があるため E2E(`e2e/`)に置く。 -ユニットは `setHeaders` が実際にヘッダーに乗るか(= SvelteKit 本体の挙動)や `set-cookie` 不在を**検証できない**点に注意。 - -### ① ユニット — 分岐ロジック(`src/routes/problems/+page.server.test.ts`) - -`load()` を直接呼び、依存をモックして分岐を検証する。**価値の中心は負のケース**(ログイン時に共有キャッシュさせない=個人情報漏洩防止のセキュリティ不変条件)。 -正のケースは値調整で割れない緩いアサーションにする(完全一致は避ける)。 - -**注意: 本コードベースに route-load のユニットテスト・`setHeaders` 使用例ともに前例なし**(新パターンを敷く)。 - -モック対象: - -- `$features/votes/services/vote_statistics` の `getVoteGradeStatistics`(成功=Map / throw の両系統) -- `$lib/services/task_results`(`getTaskResults` / `getTasksWithTagIds`) -- `locals.auth.validate`(null=匿名 / session オブジェクト=ログイン) -- `url`(`searchParams.get` が tagIds を返す/返さない)/`setHeaders` は `vi.fn()` スパイ - -テスト名と検証ケース: - -- `does not set cache-control for logged-in users` → ログイン時 `setHeaders` が**呼ばれない**(最重要・セキュリティ) -- `does not cache a degraded response when vote stats fail` → 匿名 + vote stats throw → `setHeaders` が**呼ばれない** -- `sets a public shared-cache header for anonymous users` → 匿名 + 成功 → `setHeaders` が1回、引数の `cache-control` が `public` と `s-maxage=300` を**含む**こと(緩いアサーション=完全一致しない) - -### ② E2E — キャッシュ適格性(`e2e/` に1本) - -匿名で dev server に GET し、**ユニットでは見えない実契約**を確認する。 - -テスト名: `anonymous /problems response is cache-eligible (cache-control set, no set-cookie)` - -- レスポンスに `cache-control` が付く(ローカルは Vercel 非経由のため `s-maxage` まで見える) -- **`set-cookie` ヘッダーが無い**(キャッシュ適格の本当の条件。ユニットでは auth モックにより検証不能) -- (任意)ログイン状態の同ページでは `cache-control` が**付かない**ことも併せて確認 - -## 検証(手動・デプロイ後) - -1. `pnpm test:unit` でテスト通過。 -2. **ローカル(`pnpm dev`、Vercel プロキシ非経由)**で匿名リクエストのヘッダーを確認: - - `cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=600` がそのまま出る - - **`set-cookie` が出ない**(出ると Vercel はキャッシュしない。クリーンな匿名リクエストで確認。古い無効 session cookie 保持時のみ Lucia がクリア用 Set-Cookie を出す稀な edge は許容=その1リクエストだけ非キャッシュ) -3. ログイン状態で同ページを開き、`cache-control` が**付かない**(=デフォルトの非キャッシュ)ことを確認。 -4. **本番/プレビュー(Vercel 経由)**では `s-maxage`・`stale-while-revalidate` はエッジで削除されブラウザに見えない(公式挙動2)。キャッシュ動作は **`x-vercel-cache`**(`MISS`→`HIT`→失効後 `STALE`)で確認。`pragma: no-cache` 付きで同期再検証=`REVALIDATED` も確認可。Vercel ダッシュボードで Duration / Fast Origin Transfer を 1〜2週間観測。 - -## 完了後 - -親 plan [docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) の Phase 3 セクションに完了マークと、判明した novel lesson(degraded 応答のキャッシュ回避、route-load テストの新規ハーネス)を反映する。 From be642730acc38f521e830952baf5e95d82f6330e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 15 Jun 2026 11:17:16 +0000 Subject: [PATCH 4/4] test(problems): tighten cache-control assertions to full header value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit: replace loose toContain() with exact toBe() for the full directive string — partial matches let malformed headers silently pass. E2E: add max-age=0, stale-while-revalidate=600, and public assertions to cover the complete contract; logged-in test also asserts public is absent. Co-Authored-By: Claude Sonnet 4.6 --- e2e/problems_cache.spec.ts | 3 +++ src/routes/problems/page_server.test.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/e2e/problems_cache.spec.ts b/e2e/problems_cache.spec.ts index e93edf709..de61c5c9f 100644 --- a/e2e/problems_cache.spec.ts +++ b/e2e/problems_cache.spec.ts @@ -14,7 +14,9 @@ test.describe('anonymous /problems response', () => { // Local dev server (pnpm preview) is not behind Vercel edge, so s-maxage is visible. expect(headers['cache-control']).toContain('public'); + expect(headers['cache-control']).toContain('max-age=0'); expect(headers['cache-control']).toContain('s-maxage=300'); + expect(headers['cache-control']).toContain('stale-while-revalidate=600'); // set-cookie makes a response ineligible for CDN caching. // Lucia must not attach a session cookie to anonymous requests. @@ -36,6 +38,7 @@ test.describe('logged-in /problems response', () => { const headers = response.headers(); // Personalized responses must never be shared-cached. + expect(headers['cache-control'] ?? '').not.toContain('public'); expect(headers['cache-control'] ?? '').not.toContain('s-maxage'); }); }); diff --git a/src/routes/problems/page_server.test.ts b/src/routes/problems/page_server.test.ts index a35797766..28ee6d845 100644 --- a/src/routes/problems/page_server.test.ts +++ b/src/routes/problems/page_server.test.ts @@ -68,8 +68,9 @@ describe('load() cache-control behaviour', () => { expect(event.setHeaders).toHaveBeenCalledOnce(); const headerArg = event.setHeaders.mock.calls[0][0] as Record; - expect(headerArg['Cache-Control']).toContain('public'); - expect(headerArg['Cache-Control']).toContain('s-maxage=300'); + expect(headerArg['Cache-Control']).toBe( + 'public, max-age=0, s-maxage=300, stale-while-revalidate=600', + ); }); test('anonymous users with tagIds also get a public shared-cache header', async () => { @@ -79,8 +80,9 @@ describe('load() cache-control behaviour', () => { expect(event.setHeaders).toHaveBeenCalledOnce(); const headerArg = event.setHeaders.mock.calls[0][0] as Record; - expect(headerArg['Cache-Control']).toContain('public'); - expect(headerArg['Cache-Control']).toContain('s-maxage=300'); + expect(headerArg['Cache-Control']).toBe( + 'public, max-age=0, s-maxage=300, stale-while-revalidate=600', + ); }); });