perf(frontend): 배포 버전을 ETag로 재검증한다
This commit is contained in:
@@ -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로 한 번 안내합니다.
|
||||
|
||||
## 개발 환경
|
||||
|
||||
|
||||
@@ -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<Promise<{ url: string; ifNoneMatch: string | null }>> = [];
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
let lastNotifiedCommitSha: string | null = null;
|
||||
|
||||
@@ -48,10 +46,8 @@ export const createDeploymentVersionChecker = (options: DeploymentVersionChecker
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
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());
|
||||
|
||||
@@ -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<string, string>();
|
||||
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 () => {
|
||||
|
||||
@@ -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를 통과해 실제 서빙되기 시작한
|
||||
뒤에만 달라집니다.
|
||||
|
||||
@@ -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`만 명시적
|
||||
|
||||
Reference in New Issue
Block a user