diff --git a/README.md b/README.md index db4c9008..53122493 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에 `package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는 내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는 full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로 -식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고, -현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다. +식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로 +주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 +toast로 한 번 안내합니다. ## 개발 환경 diff --git a/app/game-frontend/e2e/deploymentVersionNotice.spec.ts b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts index f8a40e43..514f55af 100644 --- a/app/game-frontend/e2e/deploymentVersionNotice.spec.ts +++ b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts @@ -8,8 +8,10 @@ const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 const installVersionFixture = async (page: Page) => { let availableCommitSha = currentCommitSha; let requests = 0; - await page.route('**/deployment-version.json*', async (route) => { + const requestUrls: string[] = []; + await page.route('**/deployment-version.json', async (route) => { requests += 1; + requestUrls.push(route.request().url()); await route.fulfill({ status: 200, contentType: 'application/json', @@ -21,9 +23,46 @@ const installVersionFixture = async (page: Page) => { availableCommitSha = nextCommitSha; }, requestCount: () => requests, + requestUrls: () => requestUrls, }; }; +test('revalidates the stable production version URL with the browser-managed ETag', async ({ page }) => { + test.skip(process.env.PLAYWRIGHT_FRONTEND_MODE !== 'production', 'Vite production ETag is required.'); + const requests: Array> = []; + const responseStatuses: number[] = []; + page.on('request', (request) => { + if (!new URL(request.url()).pathname.endsWith('/deployment-version.json')) return; + requests.push( + request.headerValue('if-none-match').then((ifNoneMatch) => ({ + url: request.url(), + ifNoneMatch, + })) + ); + }); + page.on('response', (response) => { + if (!new URL(response.url()).pathname.endsWith('/deployment-version.json')) return; + responseStatuses.push(response.status()); + }); + + await page.goto(gamePath('/version-notice-etag-fixture')); + await expect.poll(() => requests.length).toBeGreaterThan(0); + await expect.poll(() => responseStatuses.length).toBeGreaterThan(0); + const completedResponses = responseStatuses.length; + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect.poll(() => requests.length).toBeGreaterThan(1); + await expect.poll(() => responseStatuses.length).toBeGreaterThan(completedResponses); + const requestDetails = await Promise.all(requests); + + expect(new Set(requestDetails.map(({ url }) => url))).toEqual( + new Set([new URL('deployment-version.json', page.url()).toString()]) + ); + expect(requestDetails[0]?.ifNoneMatch).toBeNull(); + expect(requestDetails.slice(1).some(({ ifNoneMatch }) => Boolean(ifNoneMatch))).toBe(true); + // Chromium exposes a successfully revalidated cached response to Fetch as the usable 200 response. + expect(responseStatuses.every((status) => status === 200)).toBe(true); +}); + for (const viewport of [ { name: 'desktop', width: 1280, height: 800 }, { name: 'mobile', width: 390, height: 844 }, @@ -34,6 +73,7 @@ for (const viewport of [ await page.goto(gamePath('/version-notice-fixture')); await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible(); await expect.poll(fixture.requestCount).toBeGreaterThan(0); + expect(fixture.requestUrls().every((url) => new URL(url).search === '')).toBe(true); await page.evaluate(() => { Object.assign(window, { __versionNoticePageMarker: 'kept' }); }); diff --git a/app/game-frontend/src/config/deploymentVersion.ts b/app/game-frontend/src/config/deploymentVersion.ts index 288f34ab..08a7b9b1 100644 --- a/app/game-frontend/src/config/deploymentVersion.ts +++ b/app/game-frontend/src/config/deploymentVersion.ts @@ -7,7 +7,6 @@ export type DeploymentVersionCheckerOptions = { versionUrl: string; fetchVersion?: typeof fetch; storage?: VersionStorage; - now?: () => number; onVersionChanged: (availableCommitSha: string) => void; }; @@ -26,7 +25,6 @@ const notificationStorageKey = (versionUrl: string, availableCommitSha: string): export const createDeploymentVersionChecker = (options: DeploymentVersionCheckerOptions) => { const currentCommitSha = options.currentCommitSha.trim().toLowerCase(); const fetchVersion = options.fetchVersion ?? fetch; - const now = options.now ?? Date.now; let inFlight: Promise | null = null; let lastNotifiedCommitSha: string | null = null; @@ -48,10 +46,8 @@ export const createDeploymentVersionChecker = (options: DeploymentVersionChecker const run = async (): Promise => { if (!fullCommitShaPattern.test(currentCommitSha)) return; - const separator = options.versionUrl.includes('?') ? '&' : '?'; - const response = await fetchVersion(`${options.versionUrl}${separator}t=${now()}`, { - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' }, + const response = await fetchVersion(options.versionUrl, { + cache: 'no-cache', }); if (!response.ok) return; const availableCommitSha = parseDeploymentCommitSha(await response.json()); diff --git a/app/game-frontend/test/deploymentVersionNotice.test.ts b/app/game-frontend/test/deploymentVersionNotice.test.ts index 47bae45f..96926f69 100644 --- a/app/game-frontend/test/deploymentVersionNotice.test.ts +++ b/app/game-frontend/test/deploymentVersionNotice.test.ts @@ -28,7 +28,7 @@ void test('exposes only the read-only build commit in the deployment version ass assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha }); }); -void test('notifies once per available version and bypasses browser caches', async () => { +void test('notifies once per available version while revalidating one stable URL', async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; const stored = new Map(); const notifications: string[] = []; @@ -36,7 +36,6 @@ void test('notifies once per available version and bypasses browser caches', asy const checker = createDeploymentVersionChecker({ currentCommitSha, versionUrl: '/che/deployment-version.json', - now: () => 1234, storage: { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => void stored.set(key, value), @@ -56,9 +55,18 @@ void test('notifies once per available version and bypasses browser caches', asy await checker.check(); assert.deepEqual(notifications, [nextCommitSha, laterCommitSha]); - assert.equal(requests.every(({ url }) => url === '/che/deployment-version.json?t=1234'), true); - assert.equal(requests.every(({ init }) => init?.cache === 'no-store'), true); - assert.equal(requests.every(({ init }) => new Headers(init?.headers).get('Cache-Control') === 'no-cache'), true); + assert.equal( + requests.every(({ url }) => url === '/che/deployment-version.json'), + true + ); + assert.equal( + requests.every(({ init }) => init?.cache === 'no-cache'), + true + ); + assert.equal( + requests.every(({ init }) => init?.headers === undefined), + true + ); }); void test('shares the once-only notice within the current tab session', async () => { diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index b5cd72bd..4e8b2bc7 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -140,9 +140,11 @@ commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orch game frontend는 bundle에 고정된 commit과 이 정적 문서의 commit을 최초 mount, 60초 주기, tab visibility 복귀와 network online 복귀 때 비교합니다. 다른 full SHA를 처음 관찰하면 공용 info toast로 새로고침 안내만 하고 reload를 강제하지 않습니다. -조회에는 cache-busting query와 `cache: no-store`를 사용하며, 실패·잘못된 문서는 -현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab session storage에만 남으므로 -Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이 없습니다. Profile `DEPLOY`가 +조회 URL은 고정하고 fetch `cache: no-cache`로 browser HTTP cache의 ETag를 매번 +재검증합니다. 같은 artifact면 본문 없는 `304`, 새 artifact면 최신 JSON `200`을 +사용하며, 실패·잘못된 문서는 현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab +session storage에만 남으므로 Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이 +없습니다. Profile `DEPLOY`가 API·engine·frontend를 같은 commit worktree에서 함께 전환하므로 이 frontend 문서는 해당 profile의 새 backend/frontend release가 readiness를 통과해 실제 서빙되기 시작한 뒤에만 달라집니다. diff --git a/docs/release-operations.md b/docs/release-operations.md index 40d813fa..6a36062d 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -141,9 +141,10 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를 따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지 않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다. 각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다. -열린 profile 탭은 이 파일을 cache 우회로 확인해 bundle SHA와 달라졌을 때 한 번만 -`새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를 표시합니다. -서버는 reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해 +열린 profile 탭은 이 파일의 고정 URL을 ETag로 조건부 재검증해 bundle SHA와 달라졌을 +때 한 번만 `새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를 +표시합니다. 같은 artifact의 응답은 `304`라 본문을 다시 전송하지 않습니다. 서버는 +reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해 game/Gateway DB와 Redis에 영향을 주지 않습니다. `RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼 `gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적