fix: 배포 중 화면 유지와 자동 재연결 적용
정상 DEPLOY 중에는 기존 정적 프론트엔드를 유지하고 새 artifact 준비 후 전환합니다. 게임 화면은 일시적인 gateway 오류를 별도 상태로 표시하고 읽기 전용 probe로 제한적으로 복구합니다.
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user