feat: profile 새 버전 안내 toast를 추가한다

배포 bundle과 같은 commit의 정적 버전 문서를 생성하고, 열린 탭이 DB mutation 없이 변경을 감지하도록 한다. 동일 버전은 tab session에서 한 번만 안내하며 강제 새로고침은 하지 않는다.
This commit is contained in:
2026-08-22 05:11:50 +00:00
parent 55f41601e6
commit 5549f0c1e8
12 changed files with 343 additions and 4 deletions
+2 -1
View File
@@ -85,7 +85,8 @@ 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로 한 번 안내합니다.
## 개발 환경
@@ -0,0 +1,64 @@
import { expect, test, type Page } from '@playwright/test';
import { gamePath } from './gameTestPaths.js';
const currentCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
const installVersionFixture = async (page: Page) => {
let availableCommitSha = currentCommitSha;
let requests = 0;
await page.route('**/deployment-version.json*', async (route) => {
requests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ commitSha: availableCommitSha }),
});
});
return {
deployNextVersion: () => {
availableCommitSha = nextCommitSha;
},
requestCount: () => requests,
};
};
for (const viewport of [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`shows one quiet update toast without forcing reload on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const fixture = await installVersionFixture(page);
await page.goto(gamePath('/version-notice-fixture'));
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
await expect.poll(fixture.requestCount).toBeGreaterThan(0);
await page.evaluate(() => {
Object.assign(window, { __versionNoticePageMarker: 'kept' });
});
fixture.deployNextVersion();
await expect
.poll(async () => {
await page.evaluate(() => window.dispatchEvent(new Event('online')));
return fixture.requestCount();
})
.toBeGreaterThan(1);
const toast = page.getByTestId('game-toast').filter({ hasText: noticeMessage });
await expect(toast).toBeVisible();
await expect(toast).toHaveAttribute('data-feedback-kind', 'info');
await expect(toast).toHaveCSS('transform', 'none');
const box = await toast.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
expect(await page.evaluate(() => Reflect.get(window, '__versionNoticePageMarker'))).toBe('kept');
await page.getByRole('button', { name: '알림 닫기' }).click();
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect(toast).toHaveCount(0);
});
}
@@ -42,6 +42,7 @@ export default defineConfig({
'session-auth.spec.ts',
'npcPossession.spec.ts',
'joinLayout.spec.ts',
'deploymentVersionNotice.spec.ts',
],
fullyParallel: false,
workers: 1,
+3
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice();
</script>
<template>
@@ -0,0 +1,45 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { createDeploymentVersionChecker } from '../config/deploymentVersion';
import { useGameFeedback } from './useGameFeedback';
const pollIntervalMs = 60_000;
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
const resolveSessionStorage = (): Pick<Storage, 'getItem' | 'setItem'> | undefined => {
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
export const useDeploymentVersionNotice = (): void => {
const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
const { info: showInfoToast } = useGameFeedback();
const checker = createDeploymentVersionChecker({
currentCommitSha,
versionUrl,
storage: resolveSessionStorage(),
onVersionChanged: () => showInfoToast(noticeMessage),
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
const checkWhenVisible = (): void => {
if (document.visibilityState === 'visible') void checker.check();
};
onMounted(() => {
void checker.check();
pollTimer = setInterval(checkWhenVisible, pollIntervalMs);
document.addEventListener('visibilitychange', checkWhenVisible);
window.addEventListener('online', checkWhenVisible);
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
document.removeEventListener('visibilitychange', checkWhenVisible);
window.removeEventListener('online', checkWhenVisible);
});
};
@@ -0,0 +1,78 @@
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
type VersionStorage = Pick<Storage, 'getItem' | 'setItem'>;
export type DeploymentVersionCheckerOptions = {
currentCommitSha: string;
versionUrl: string;
fetchVersion?: typeof fetch;
storage?: VersionStorage;
now?: () => number;
onVersionChanged: (availableCommitSha: string) => void;
};
export const deploymentVersionAssetSource = (buildCommitSha: string): string =>
`${JSON.stringify({ commitSha: buildCommitSha })}\n`;
export const parseDeploymentCommitSha = (payload: unknown): string | null => {
if (!payload || typeof payload !== 'object' || !('commitSha' in payload)) return null;
const commitSha = String(payload.commitSha).trim().toLowerCase();
return fullCommitShaPattern.test(commitSha) ? commitSha : null;
};
const notificationStorageKey = (versionUrl: string, availableCommitSha: string): string =>
`sammo:deployment-version-notice:${versionUrl}:${availableCommitSha}`;
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;
const wasNotified = (key: string): boolean => {
try {
return options.storage?.getItem(key) === '1';
} catch {
return false;
}
};
const rememberNotification = (key: string): void => {
try {
options.storage?.setItem(key, '1');
} catch {
// Session storage can be unavailable under restrictive browser policies.
}
};
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' },
});
if (!response.ok) return;
const availableCommitSha = parseDeploymentCommitSha(await response.json());
if (!availableCommitSha || availableCommitSha === currentCommitSha) return;
const storageKey = notificationStorageKey(options.versionUrl, availableCommitSha);
if (lastNotifiedCommitSha === availableCommitSha || wasNotified(storageKey)) return;
lastNotifiedCommitSha = availableCommitSha;
rememberNotification(storageKey);
options.onVersionChanged(availableCommitSha);
};
return {
check: (): Promise<void> => {
if (inFlight) return inFlight;
inFlight = run()
.catch(() => undefined)
.finally(() => {
inFlight = null;
});
return inFlight;
},
};
};
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createDeploymentVersionChecker,
deploymentVersionAssetSource,
parseDeploymentCommitSha,
} from '../src/config/deploymentVersion.ts';
const currentCommitSha = '0123456789abcdef0123456789abcdef01234567';
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
const laterCommitSha = 'fedcba9876543210fedcba9876543210fedcba98';
const jsonResponse = (commitSha: string): Response =>
new Response(JSON.stringify({ commitSha }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
void test('accepts only full hexadecimal deployment commit values', () => {
assert.equal(parseDeploymentCommitSha({ commitSha: nextCommitSha.toUpperCase() }), nextCommitSha);
assert.equal(parseDeploymentCommitSha({ commitSha: 'main' }), null);
assert.equal(parseDeploymentCommitSha({ version: nextCommitSha }), null);
assert.equal(parseDeploymentCommitSha(null), null);
});
void test('exposes only the read-only build commit in the deployment version asset', () => {
assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha });
});
void test('notifies once per available version and bypasses browser caches', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const stored = new Map<string, string>();
const notifications: string[] = [];
let availableCommitSha = currentCommitSha;
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),
},
fetchVersion: async (url, init) => {
requests.push({ url: String(url), init });
return jsonResponse(availableCommitSha);
},
onVersionChanged: (commitSha) => notifications.push(commitSha),
});
await checker.check();
availableCommitSha = nextCommitSha;
await checker.check();
await checker.check();
availableCommitSha = laterCommitSha;
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);
});
void test('shares the once-only notice within the current tab session', async () => {
const stored = new Map<string, string>();
const storage = {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => void stored.set(key, value),
};
let notifications = 0;
const options = {
currentCommitSha,
versionUrl: '/hwe/deployment-version.json',
storage,
fetchVersion: async () => jsonResponse(nextCommitSha),
onVersionChanged: () => notifications++,
};
await createDeploymentVersionChecker(options).check();
await createDeploymentVersionChecker(options).check();
assert.equal(notifications, 1);
});
void test('ignores unavailable or malformed version documents without disrupting the page', async () => {
let notifications = 0;
const unavailable = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
fetchVersion: async () => {
throw new Error('offline');
},
onVersionChanged: () => notifications++,
});
const malformed = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
fetchVersion: async () => new Response('{', { status: 200 }),
onVersionChanged: () => notifications++,
});
await unavailable.check();
await malformed.check();
assert.equal(notifications, 0);
});
+11
View File
@@ -48,6 +48,17 @@ void describe('game frontend Vite config', () => {
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
JSON.stringify(commitSha.toLowerCase())
);
assert.equal(
loaded?.config.plugins?.some(
(plugin) =>
plugin !== null &&
typeof plugin === 'object' &&
!Array.isArray(plugin) &&
'name' in plugin &&
plugin.name === 'sammo-deployment-version'
),
true
);
} finally {
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
+15 -3
View File
@@ -1,9 +1,10 @@
import { defineConfig, loadEnv } from 'vite';
import { defineConfig, loadEnv, type Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import { execFileSync } from 'node:child_process';
import path from 'path';
import { mergeViteEnv } from './src/config/viteEnv';
import { deploymentVersionAssetSource } from './src/config/deploymentVersion.ts';
import { mergeViteEnv } from './src/config/viteEnv.ts';
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
@@ -24,6 +25,17 @@ export const resolveBuildCommitSha = (explicitSha: string | undefined, repositor
}
};
export const createDeploymentVersionPlugin = (buildCommitSha: string): Plugin => ({
name: 'sammo-deployment-version',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'deployment-version.json',
source: deploymentVersionAssetSource(buildCommitSha),
});
},
});
const normalizeBasePath = (value: string | undefined): string => {
const pathValue = (value ?? '/').trim();
if (!pathValue || pathValue === '/') {
@@ -50,7 +62,7 @@ export default defineConfig(({ mode }) => {
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
return {
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
plugins: [vue(), tailwindcss()],
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
define: {
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
},
@@ -23,11 +23,15 @@ describe('profile frontend materializer', () => {
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(source, 'index.html'), 'new release');
await writeFile(path.join(source, 'deployment-version.json'), '{"commitSha":"new"}\n');
await writeFile(path.join(target, 'index.html'), 'old release');
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release');
await expect(readFile(path.join(target, 'deployment-version.json'), 'utf8')).resolves.toBe(
'{"commitSha":"new"}\n'
);
});
it('keeps an existing artifact untouched when the cached build is missing', async () => {
+10
View File
@@ -136,6 +136,16 @@ Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
읽을 수 없을 때만 `unknown`을 표시합니다.
같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
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`
API·engine·frontend를 같은 commit worktree에서 함께 전환하므로 이 frontend 문서는
해당 profile의 새 backend/frontend release가 readiness를 통과해 실제 서빙되기 시작한
뒤에만 달라집니다.
`RESET` operation은 같은 build 경계를 사용한 뒤 현재 시즌 테이블을 seed로
교체합니다. Seeder의 reset 목록에는 `hall`, `ng_games`, `yearbook_history`,
과거 장수·국가와 상속·진단 자료가 포함되지 않습니다.
+5
View File
@@ -140,6 +140,11 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
따라서 같은 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만 사용해
game/Gateway DB와 Redis에 영향을 주지 않습니다.
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
server target을 `game-api`로 제한합니다.