fix: 배포 중 화면 유지와 자동 재연결 적용

정상 DEPLOY 중에는 기존 정적 프론트엔드를 유지하고 새 artifact 준비 후 전환합니다. 게임 화면은 일시적인 gateway 오류를 별도 상태로 표시하고 읽기 전용 probe로 제한적으로 복구합니다.
This commit is contained in:
2026-08-27 02:08:44 +00:00
parent 9e01d24b5d
commit c1d5a80f79
13 changed files with 395 additions and 12 deletions
+2
View File
@@ -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();
+27 -3
View File
@@ -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();