fix: 배포 중 화면 유지와 자동 재연결 적용
정상 DEPLOY 중에는 기존 정적 프론트엔드를 유지하고 새 artifact 준비 후 전환합니다. 게임 화면은 일시적인 gateway 오류를 별도 상태로 표시하고 읽기 전용 probe로 제한적으로 복구합니다.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installRecoveryFixture = async (page: Page) => {
|
||||
let unavailable = true;
|
||||
let lobbyRequests = 0;
|
||||
await page.addInitScript(
|
||||
({ token, profile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', profile);
|
||||
window.addEventListener('sammo:game-server-reconnected', () => {
|
||||
const state = window as typeof window & { __gameServerReconnects?: number };
|
||||
state.__gameServerReconnects = (state.__gameServerReconnects ?? 0) + 1;
|
||||
});
|
||||
},
|
||||
{ token: 'ga_recovery', profile: gameProfile }
|
||||
);
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
const operations = operationNames(route);
|
||||
if (operations.includes('lobby.info')) {
|
||||
lobbyRequests += 1;
|
||||
if (unavailable) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'profile switch in progress' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
operations.map((operation) => {
|
||||
if (operation === 'auth.status') return response({ userId: 'recovery-user' });
|
||||
if (operation === 'lobby.info') return response({ myGeneral: null });
|
||||
if (operation === 'join.getConfig') return response({});
|
||||
return response(null);
|
||||
})
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
recover: () => {
|
||||
unavailable = false;
|
||||
},
|
||||
lobbyRequests: () => lobbyRequests,
|
||||
};
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1280, height: 800 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps the current screen and reconnects after a transient profile switch on ${viewport.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const fixture = await installRecoveryFixture(page);
|
||||
await page.goto('select-general');
|
||||
|
||||
const heading = page.locator('.page-title');
|
||||
await expect(heading).toContainText('장 수 선 택');
|
||||
const notice = page.getByTestId('game-server-connection-notice');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText('화면을 유지한 채 자동으로 다시 연결합니다');
|
||||
await expect(notice).toHaveCSS('position', 'fixed');
|
||||
const noticeBox = await notice.boundingBox();
|
||||
expect(noticeBox).not.toBeNull();
|
||||
expect(noticeBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeBox!.x + noticeBox!.width).toBeLessThanOrEqual(viewport.width);
|
||||
const documentWidthDuringReconnect = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
await page.evaluate(() => {
|
||||
Object.assign(window, { __connectionRecoveryPageMarker: 'kept' });
|
||||
});
|
||||
|
||||
fixture.recover();
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
|
||||
await expect(notice).toHaveCount(0);
|
||||
await expect(heading).toContainText('장 수 선 택');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(documentWidthDuringReconnect);
|
||||
expect(await page.evaluate(() => Reflect.get(window, '__connectionRecoveryPageMarker'))).toBe('kept');
|
||||
expect(await page.evaluate(() => Reflect.get(window, '__gameServerReconnects'))).toBe(1);
|
||||
expect(fixture.lobbyRequests()).toBeGreaterThanOrEqual(2);
|
||||
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_recovery');
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export default defineConfig({
|
||||
'npcPossession.spec.ts',
|
||||
'joinLayout.spec.ts',
|
||||
'deploymentVersionNotice.spec.ts',
|
||||
'connectionRecovery.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -159,6 +159,6 @@ test('keeps a valid ga_ token when only lobby.info is unavailable', async ({ pag
|
||||
await expect(page.locator('.page-title')).toContainText('장 수 선 택');
|
||||
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_valid');
|
||||
expect(statusRequests).toBe(1);
|
||||
expect(lobbyRequests).toBe(1);
|
||||
expect(lobbyRequests).toBeGreaterThanOrEqual(1);
|
||||
expect(gatewayRequests).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
|
||||
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
|
||||
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
|
||||
|
||||
@@ -8,6 +9,7 @@ useDeploymentVersionNotice();
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<GameServerConnectionNotice />
|
||||
<GameFeedbackLayer />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useGameServerConnectionRecovery } from '../../composables/useGameServerConnectionRecovery';
|
||||
|
||||
const { reconnecting } = useGameServerConnectionRecovery();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="reconnecting"
|
||||
class="game-server-connection-notice"
|
||||
data-testid="game-server-connection-notice"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
서버 연결이 잠시 끊겼습니다. 화면을 유지한 채 자동으로 다시 연결합니다.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-server-connection-notice {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 520px);
|
||||
padding: 7px 12px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #a67c00;
|
||||
border-top: 0;
|
||||
background: #fff3bf;
|
||||
color: #3d3100;
|
||||
font: 13px/1.45 var(--sammo-font-sans);
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 25%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import {
|
||||
GAME_SERVER_RECONNECTED_EVENT,
|
||||
gameServerConnection,
|
||||
retryDelayForFailure,
|
||||
} from '../utils/gameServerConnection';
|
||||
|
||||
export const useGameServerConnectionRecovery = () => {
|
||||
const reconnecting = computed(() => gameServerConnection.status.value === 'reconnecting');
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let failureCount = 0;
|
||||
let mounted = false;
|
||||
let wasReconnecting = false;
|
||||
|
||||
const clearRetry = (): void => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
|
||||
const scheduleRetry = (): void => {
|
||||
if (!mounted || !reconnecting.value || retryTimer) return;
|
||||
failureCount += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void trpc.lobby.info
|
||||
.query()
|
||||
.catch(() => undefined)
|
||||
.finally(() => scheduleRetry());
|
||||
}, retryDelayForFailure(failureCount));
|
||||
};
|
||||
|
||||
const retryNow = (): void => {
|
||||
if (!reconnecting.value) return;
|
||||
clearRetry();
|
||||
failureCount = 0;
|
||||
void trpc.lobby.info
|
||||
.query()
|
||||
.catch(() => undefined)
|
||||
.finally(() => scheduleRetry());
|
||||
};
|
||||
|
||||
const stopWatching = watch(
|
||||
reconnecting,
|
||||
(current) => {
|
||||
if (current) {
|
||||
wasReconnecting = true;
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
clearRetry();
|
||||
failureCount = 0;
|
||||
if (wasReconnecting && mounted) {
|
||||
window.dispatchEvent(new Event(GAME_SERVER_RECONNECTED_EVENT));
|
||||
}
|
||||
wasReconnecting = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true;
|
||||
window.addEventListener('online', retryNow);
|
||||
scheduleRetry();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false;
|
||||
clearRetry();
|
||||
stopWatching();
|
||||
window.removeEventListener('online', retryNow);
|
||||
});
|
||||
|
||||
return { reconnecting };
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../
|
||||
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
|
||||
import { markGameServerContact } from '../utils/gameServerActivity';
|
||||
import { GAME_SERVER_RECONNECTED_EVENT } from '../utils/gameServerConnection';
|
||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||
|
||||
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||
@@ -1261,12 +1262,19 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
|
||||
};
|
||||
|
||||
const handleGameServerReconnected = () => {
|
||||
if (!realtimeActive.value || document.visibilityState === 'hidden') return;
|
||||
realtimeRefreshQueue.beginCooldown();
|
||||
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
|
||||
};
|
||||
|
||||
const startRealtime = () => {
|
||||
if (typeof window === 'undefined' || realtimeActive.value) return;
|
||||
realtimeActive.value = true;
|
||||
realtimeRefreshQueue.beginCooldown();
|
||||
if (!visibilityListenerInstalled) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
|
||||
visibilityListenerInstalled = true;
|
||||
}
|
||||
reconcileRealtimeCoordinator();
|
||||
@@ -1279,6 +1287,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
closeRealtimeCoordinator();
|
||||
if (visibilityListenerInstalled) {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener(GAME_SERVER_RECONNECTED_EVENT, handleGameServerReconnected);
|
||||
visibilityListenerInstalled = false;
|
||||
}
|
||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readonly, ref, type Ref } from 'vue';
|
||||
|
||||
export const GAME_SERVER_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000] as const;
|
||||
export const GAME_SERVER_RECONNECTED_EVENT = 'sammo:game-server-reconnected';
|
||||
|
||||
export type GameServerConnectionStatus = 'connected' | 'reconnecting';
|
||||
|
||||
export type GameServerConnectionTracker = {
|
||||
status: Readonly<Ref<GameServerConnectionStatus>>;
|
||||
markFailure: () => void;
|
||||
markConnected: () => void;
|
||||
};
|
||||
|
||||
export const isRetryableGameServerStatus = (status: number): boolean =>
|
||||
status === 502 || status === 503 || status === 504;
|
||||
|
||||
export const canConfirmGameServerRecovery = (status: number): boolean => status < 500;
|
||||
|
||||
export const isGameServerRecoveryRequest = (input: RequestInfo | URL): boolean => {
|
||||
const rawUrl = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
|
||||
try {
|
||||
const operationList = decodeURIComponent(new URL(rawUrl, 'http://game.local').pathname).split('/').at(-1);
|
||||
return operationList?.split(',').includes('lobby.info') ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isAbortedGameServerRequest = (error: unknown): boolean =>
|
||||
error instanceof DOMException && error.name === 'AbortError';
|
||||
|
||||
export const retryDelayForFailure = (failureCount: number): number =>
|
||||
GAME_SERVER_RETRY_DELAYS_MS[
|
||||
Math.min(Math.max(0, Math.trunc(failureCount) - 1), GAME_SERVER_RETRY_DELAYS_MS.length - 1)
|
||||
];
|
||||
|
||||
export const createGameServerConnectionTracker = (): GameServerConnectionTracker => {
|
||||
const status = ref<GameServerConnectionStatus>('connected');
|
||||
|
||||
return {
|
||||
status: readonly(status),
|
||||
markFailure() {
|
||||
status.value = 'reconnecting';
|
||||
},
|
||||
markConnected() {
|
||||
status.value = 'connected';
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const gameServerConnection = createGameServerConnectionTracker();
|
||||
|
||||
export const markGameServerConnectionFailure = (): void => gameServerConnection.markFailure();
|
||||
export const markGameServerConnectionReady = (): void => gameServerConnection.markConnected();
|
||||
@@ -5,6 +5,15 @@ import type { AppRouter } from '@sammo-ts/game-api';
|
||||
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
||||
import { markGameServerContact } from './gameServerActivity';
|
||||
import {
|
||||
canConfirmGameServerRecovery,
|
||||
gameServerConnection,
|
||||
isAbortedGameServerRequest,
|
||||
isGameServerRecoveryRequest,
|
||||
isRetryableGameServerStatus,
|
||||
markGameServerConnectionFailure,
|
||||
markGameServerConnectionReady,
|
||||
} from './gameServerConnection';
|
||||
|
||||
const getGameToken = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -20,9 +29,24 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
url: gameFrontendRuntimeConfig.gameApiUrl,
|
||||
...trpcJsonBodyHttpClientOptions,
|
||||
async fetch(input, init) {
|
||||
const result = await globalThis.fetch(input, init);
|
||||
markGameServerContact();
|
||||
return result;
|
||||
try {
|
||||
const result = await globalThis.fetch(input, init);
|
||||
if (isRetryableGameServerStatus(result.status)) {
|
||||
markGameServerConnectionFailure();
|
||||
} else {
|
||||
markGameServerContact();
|
||||
if (
|
||||
gameServerConnection.status.value === 'connected' ||
|
||||
(isGameServerRecoveryRequest(input) && canConfirmGameServerRecovery(result.status))
|
||||
) {
|
||||
markGameServerConnectionReady();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!isAbortedGameServerRequest(error)) markGameServerConnectionFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
headers({ opList }) {
|
||||
const token = getGameToken();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
canConfirmGameServerRecovery,
|
||||
createGameServerConnectionTracker,
|
||||
isGameServerRecoveryRequest,
|
||||
isRetryableGameServerStatus,
|
||||
retryDelayForFailure,
|
||||
} from '../src/utils/gameServerConnection.ts';
|
||||
|
||||
void test('classifies only transient deployment gateway responses as reconnectable', () => {
|
||||
assert.equal(isRetryableGameServerStatus(502), true);
|
||||
assert.equal(isRetryableGameServerStatus(503), true);
|
||||
assert.equal(isRetryableGameServerStatus(504), true);
|
||||
assert.equal(isRetryableGameServerStatus(401), false);
|
||||
assert.equal(isRetryableGameServerStatus(403), false);
|
||||
assert.equal(isRetryableGameServerStatus(500), false);
|
||||
});
|
||||
|
||||
void test('does not treat a server error from the recovery probe as restored service', () => {
|
||||
assert.equal(canConfirmGameServerRecovery(200), true);
|
||||
assert.equal(canConfirmGameServerRecovery(401), true);
|
||||
assert.equal(canConfirmGameServerRecovery(403), true);
|
||||
assert.equal(canConfirmGameServerRecovery(500), false);
|
||||
assert.equal(canConfirmGameServerRecovery(503), false);
|
||||
});
|
||||
|
||||
void test('uses bounded reconnect delays', () => {
|
||||
assert.equal(retryDelayForFailure(1), 500);
|
||||
assert.equal(retryDelayForFailure(2), 1_000);
|
||||
assert.equal(retryDelayForFailure(3), 2_000);
|
||||
assert.equal(retryDelayForFailure(4), 4_000);
|
||||
assert.equal(retryDelayForFailure(20), 4_000);
|
||||
});
|
||||
|
||||
void test('recognizes only the read-only lobby probe as connection recovery evidence', () => {
|
||||
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/lobby.info'), true);
|
||||
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/auth.status,lobby.info?batch=1'), true);
|
||||
assert.equal(isGameServerRecoveryRequest('/che/api/trpc/join.getConfig'), false);
|
||||
});
|
||||
|
||||
void test('retains reconnecting state until the server responds again', () => {
|
||||
const tracker = createGameServerConnectionTracker();
|
||||
tracker.markFailure();
|
||||
tracker.markFailure();
|
||||
assert.equal(tracker.status.value, 'reconnecting');
|
||||
tracker.markConnected();
|
||||
assert.equal(tracker.status.value, 'connected');
|
||||
});
|
||||
Reference in New Issue
Block a user