오류 정지 중 가입 턴을 고정하고 화면 이동 실패 복구 지원

This commit is contained in:
2026-09-12 02:02:31 +00:00
parent 5041fc365e
commit 21d10d5dfc
15 changed files with 448 additions and 26 deletions
@@ -142,11 +142,12 @@ export class TurnDaemonLifecycle {
private async runLoop(): Promise<void> {
await this.initializeState();
while (!this.stopping) {
// 정지 시계 동기화보다 먼저 claim하면 가입에 벽시계 경과가 섞인다.
const gatePaused = (await this.pauseGate?.()) ?? false;
await this.drainCommands();
if (this.stopping) {
break;
}
const gatePaused = (await this.pauseGate?.()) ?? false;
if (this.errorPaused && !gatePaused) {
this.errorPaused = false;
this.status.lastError = undefined;
@@ -14,6 +14,7 @@ export interface GatewayProfileGateOptions {
export interface GatewayProfileGate {
shouldPause(): Promise<boolean>;
isExplicitlyPaused(): boolean;
markPaused(error?: unknown): Promise<void>;
close(): Promise<void>;
}
@@ -30,12 +31,14 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
const prisma = connector.prisma;
let lastCheckedAt = 0;
let cachedPause = false;
let cachedStatus: GatewayProfileStatus | null = null;
const loadStatus = async (): Promise<boolean> => {
try {
const profile = await prisma.gatewayProfile.findUnique({
where: { profileName: options.profileName },
});
cachedStatus = (profile?.status as GatewayProfileStatus | undefined) ?? null;
if (!profile) {
return false;
}
@@ -46,6 +49,7 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
};
return {
isExplicitlyPaused: () => cachedStatus === 'PAUSED',
// 게이트웨이 프로필 상태를 읽어 턴 실행을 멈춰야 하는지 판단한다.
async shouldPause(): Promise<boolean> {
const now = performance.now();
@@ -57,6 +61,9 @@ export const createGatewayProfileGate = async (options: GatewayProfileGateOption
return cachedPause;
},
async markPaused(error?: unknown): Promise<void> {
cachedPause = true;
cachedStatus = 'PAUSED';
lastCheckedAt = performance.now();
const failure = error ? describeRuntimeError(error) : null;
const message = failure?.message ?? null;
try {
+13
View File
@@ -920,6 +920,19 @@ export class InMemoryTurnWorld {
return clock.now(wallNow);
}
getInitialGeneralTurnTime(processingGameTick: number): Date {
const clock = this.getGameClock();
const tick =
clock.phase === 'PREOPEN'
? Math.max(0, processingGameTick)
: clock.phase === 'SUSPENDED' || clock.phase === 'COMPLETED'
? clock.tick
: processingGameTick;
// 오류 정지 직전에 접수된 가입도 정지된 시각보다 미래에 배치하지 않는다.
// 접수 tick 자체는 RNG/감사 원장의 좌표로 보존한다.
return clock.tickToDate(tick);
}
dateToGameTick(date: Date): number {
return this.getGameClock().dateToTick(date);
}
@@ -0,0 +1,33 @@
import type { GameClockPhase } from '@sammo-ts/common';
/** Gateway의 실행 gate와 durable 시계를 명령 claim 전에 맞춘다. */
export const createRuntimePauseGate = (options: {
assertLease(): void;
shouldPause(): Promise<boolean>;
getPhase(): GameClockPhase;
isExplicitlyPaused(): boolean;
prepareRecovery(options: { paused: boolean }): Promise<void>;
synchronize(): Promise<unknown>;
}): (() => Promise<boolean>) => {
let lastPaused: boolean | null = null;
return async () => {
options.assertLease();
const paused = await options.shouldPause();
const phase = options.getPhase();
// 오류 정지는 Gateway 상태만 PAUSED로 바꿀 수 있다. 그대로 두면 가입은
// 흐르는 접수 시각을 쓰고, 재개 시 정수 턴 이동까지 중복 적용받는다.
// PREOPEN은 예정된 대기이므로 오픈 시각을 바꾸지 않는다.
if (paused && options.isExplicitlyPaused() && phase === 'RUNNING') {
await options.prepareRecovery({ paused: true });
} else if (!paused && phase === 'SUSPENDED') {
// 이 runtime이 만든 RECOVERY 정지는 재기동 없이도 재개한다.
// MAINTENANCE/통일 대기의 재개 권한은 기존 운영 경계에 남는다.
await options.prepareRecovery({ paused: false });
}
if (lastPaused !== paused || phase === 'SUSPENDED' || phase === 'RECONCILING') {
await options.synchronize();
}
lastPaused = paused;
return paused;
};
};
+19 -18
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { createRuntimePauseGate } from './runtimePauseGate.js';
import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import {
@@ -716,6 +717,7 @@ const createTurnDaemonRuntimeWithLease = async (
let stopClockProjectionWorker = () => {};
let applyClockProjection: DatabaseTurnHooks['applyClockProjection'] | undefined;
let synchronizeClockAuthority: DatabaseTurnHooks['synchronizeClockAuthority'] | undefined;
let prepareClockRecovery: DatabaseTurnHooks['prepareRealtimeRecovery'] | undefined;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(
@@ -941,11 +943,16 @@ const createTurnDaemonRuntimeWithLease = async (
onRunError: async (error) => {
await dbHooks.hooks.onRunError?.(error);
await gatewayGate?.markPaused(error);
if (!turnDaemonLease?.isLost() && world.getGameClockState().phase === 'RUNNING') {
// 같은 command batch의 다음 가입도 정지된 시각을 보게 한다.
await dbHooks.prepareRealtimeRecovery({ paused: true });
}
},
};
takeCommittedReadModelChangeReceipt = dbHooks.takeCommittedReadModelChangeReceipt;
applyClockProjection = dbHooks.applyClockProjection;
synchronizeClockAuthority = dbHooks.synchronizeClockAuthority;
prepareClockRecovery = dbHooks.prepareRealtimeRecovery;
close = async () => {
if (auctionBidder) {
await auctionBidder.close();
@@ -1060,7 +1067,6 @@ const createTurnDaemonRuntimeWithLease = async (
maxGenerals: 200,
catchUpCap: 1,
};
let lastObservedGatewayPause: boolean | null = null;
const lifecycle = new TurnDaemonLifecycle(
{
@@ -1071,23 +1077,18 @@ const createTurnDaemonRuntimeWithLease = async (
stateStore,
processor,
hooks,
pauseGate: async () => {
if (turnDaemonLease?.isLost()) {
// 만료된 owner는 재개 명령도 처리할 수 없다. 현재 runtime을
// 끝내 PM2가 새 owner와 DB snapshot으로 시작하도록 한다.
throw turnDaemonLease.getLossError();
}
const gatewayPaused = (await pauseGate?.()) ?? false;
const phase = world.getGameClockState().phase;
const phaseNeedsSync = gatewayPaused
? phase !== 'SUSPENDED'
: phase === 'SUSPENDED' || phase === 'RECONCILING';
if (synchronizeClockAuthority && (lastObservedGatewayPause !== gatewayPaused || phaseNeedsSync)) {
await synchronizeClockAuthority();
}
lastObservedGatewayPause = gatewayPaused;
return gatewayPaused;
},
pauseGate: createRuntimePauseGate({
assertLease: () => {
if (turnDaemonLease?.isLost()) throw turnDaemonLease.getLossError();
},
shouldPause: async () => (await pauseGate?.()) ?? false,
getPhase: () => world.getGameClockState().phase,
isExplicitlyPaused: () => gatewayGate?.isExplicitlyPaused() ?? false,
prepareRecovery: async (recoveryOptions) => {
await prepareClockRecovery?.(recoveryOptions);
},
synchronize: async () => synchronizeClockAuthority?.(),
}),
commandHandler,
commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
// The exclusive fixture runner aborts the entire in-memory runtime
@@ -354,9 +354,7 @@ async function handleJoinCreateGeneral(
throw new Error('joinCreateGeneral requires an authoritative daemon processing game tick.');
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
try {
return {
type: 'joinCreateGeneral',
@@ -475,9 +473,7 @@ async function handleSelectPoolCreate(
}
const acceptedAt = ctx.world.gameTickToDate(processingGameTick);
// 선택 생성도 접수/RNG의 음수 tick과 실제 최초 턴의 오픈 하한을 분리한다.
const turnScheduleAt = ctx.world.gameTickToDate(
ctx.world.getGameClockState().phase === 'PREOPEN' ? Math.max(0, processingGameTick) : processingGameTick
);
const turnScheduleAt = ctx.world.getInitialGeneralTurnTime(processingGameTick);
try {
return {
type: 'selectPoolCreate',
@@ -4,6 +4,7 @@ import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts
import {
createGamePostgresConnector,
readTurnRuntimeReady,
readInputEventClockCoordinate,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
CLOCK_OPERATION_PERSISTENCE_LOCK,
@@ -15,6 +16,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
import { resolveJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
@@ -263,6 +266,85 @@ describeIntegration('durable clock reconciliation', () => {
}
});
it('freezes joins in a live PAUSED gate and applies outage recovery only once', async () => {
const profile = 'live-pause-join';
const base = new Date('2026-09-11T23:00:00Z');
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 180,
currentMonth: 1,
tickSeconds: 60,
clockBaseTime: base,
clockTick: 0n,
lastTurnTick: 0n,
clockMode: 'realtime',
clockPhase: 'RUNNING',
clockWallAnchor: new Date(Date.now() - 115 * 60_000),
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = (await lease.acquire())!;
await lease.markClockReady();
const authority = {
kind: 'DAEMON' as const,
profileName: profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
};
let phase: 'RUNNING' | 'SUSPENDED' = 'RUNNING';
const gate = createRuntimePauseGate({
assertLease: () => {},
shouldPause: async () => true,
isExplicitlyPaused: () => true,
getPhase: () => phase,
prepareRecovery: async (options) => {
await prepareRealtimeRecovery(db, authority, options);
phase = 'SUSPENDED';
},
synchronize: async () => {},
});
const beforePause = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
expect(beforePause.gameTick).toBeGreaterThan(BigInt(100 * T));
await gate();
const accepted = await db.$transaction((tx) => readInputEventClockCoordinate(tx));
expect(accepted.gameTick).toBe(0n);
const pausedWorld = await db.worldState.findFirstOrThrow();
const draws = [26, 753000];
const turnTime = resolveJoinTurnTime(
{ nextRangeInt: () => draws.shift()! },
pausedWorld,
accepted.gameAt,
base,
undefined
);
expect(turnTime.toISOString()).toBe('2026-09-11T23:00:26.753Z');
await db.general.create({
data: { id: 768, name: 'pause-join', turnTick: BigInt(26_753 * 600), turnTime },
});
await gate();
expect(await db.clockSuspension.count()).toBe(1);
const suspension = await db.clockSuspension.findFirstOrThrow();
const plan = await reconcileClockSuspension({
db,
authority,
suspensionId: suspension.id,
testResumeWallAt: new Date(suspension.cutWallAt.getTime() + 115 * 60_000),
});
expect(plan.shiftTicks).toBe(108 * T);
const joined = await db.general.findUniqueOrThrow({ where: { id: 768 } });
expect(joined.turnTime.toISOString()).toBe('2026-09-12T00:48:26.753Z');
expect(joined.turnTick! - BigInt(plan.alignedTick)).toBe(BigInt(26_753 * 600));
await reconcileClockSuspension({ db, authority, suspensionId: suspension.id });
expect((await db.general.findUniqueOrThrow({ where: { id: 768 } })).turnTime).toEqual(joined.turnTime);
} finally {
await lease.close();
}
});
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup';
await db.worldState.create({
@@ -239,6 +239,20 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState().wallAnchor).toEqual(resumedAt);
});
it.each(['SUSPENDED', 'COMPLETED'] as const)('keeps a queued join within the frozen %s clock', (phase) => {
const base = new Date('2026-09-11T23:00:00Z');
const world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockMode: 'realtime',
clockPhase: phase,
clockWallAnchor: base,
lastTurnTick: 0,
});
expect(world.getInitialGeneralTurnTime(103 * 36_000_000)).toEqual(base);
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(base);
});
it('keeps runnable general scheduling at the future opening anchor during PREOPEN', () => {
const gameBase = new Date('2026-07-30T10:00:00.000Z');
const openAt = new Date('2026-09-02T23:30:00.000Z');
@@ -254,6 +268,7 @@ describe('runtime clock shift', () => {
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getInitialGeneralTurnTime(-36_000_000)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameClockPhase } from '@sammo-ts/common';
import { createRuntimePauseGate } from '../src/turn/runtimePauseGate.js';
describe('runtime pause clock boundary', () => {
it('freezes a live error pause before commands, resumes without restart, and does not freeze twice', async () => {
let phase: GameClockPhase = 'RUNNING';
let paused = true;
const calls: string[] = [];
const gate = createRuntimePauseGate({
assertLease: () => calls.push('lease'),
shouldPause: async () => paused,
isExplicitlyPaused: () => paused,
getPhase: () => phase,
prepareRecovery: async ({ paused }) => {
calls.push(paused ? 'freeze' : 'resume');
phase = paused ? 'SUSPENDED' : 'RECONCILING';
},
synchronize: async () => calls.push('sync'),
});
expect(await gate()).toBe(true);
expect(phase).toBe('SUSPENDED');
expect(calls).toEqual(['lease', 'freeze', 'sync']);
await gate();
expect(calls.filter((call) => call === 'freeze')).toHaveLength(1);
paused = false;
expect(await gate()).toBe(false);
expect(phase).toBe('RECONCILING');
expect(calls.slice(-3)).toEqual(['lease', 'resume', 'sync']);
});
it.each(['PREOPEN', 'RUNNING', 'COMPLETED'] as const)(
'preserves the planned opening when the Gateway is PREOPEN and the clock is %s',
async (phase) => {
const prepareRecovery = vi.fn();
const gate = createRuntimePauseGate({
assertLease: () => {},
shouldPause: async () => true,
isExplicitlyPaused: () => false,
getPhase: () => phase,
prepareRecovery,
synchronize: async () => {},
});
expect(await gate()).toBe(true);
expect(prepareRecovery).not.toHaveBeenCalled();
}
);
it('does not touch the clock after lease loss', async () => {
const prepareRecovery = vi.fn();
const gate = createRuntimePauseGate({
assertLease: () => {
throw new Error('lease lost');
},
shouldPause: async () => true,
isExplicitlyPaused: () => true,
getPhase: () => 'RUNNING',
prepareRecovery,
synchronize: async () => {},
});
await expect(gate()).rejects.toThrow('lease lost');
expect(prepareRecovery).not.toHaveBeenCalled();
});
});
@@ -19,13 +19,15 @@ describe('TurnDaemonLifecycle', () => {
const now = new Date('2026-09-09T17:30:00Z');
const error = new TurnDaemonLeaseLostError('che:default');
const processor = { run: vi.fn() };
const queue = new InMemoryControlQueue();
const drain = vi.spyOn(queue, 'drain');
const onRunError = vi.fn(async () => {
if (reportFails) throw new Error('gateway unavailable');
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new ManualClock(now.getTime()),
controlQueue: new InMemoryControlQueue(),
controlQueue: queue,
processor,
getNextTickTime: (value) => addMinutes(value, 5),
stateStore: {
@@ -45,6 +47,7 @@ describe('TurnDaemonLifecycle', () => {
await expect(lifecycle.start()).rejects.toBe(error);
expect(onRunError).toHaveBeenCalledExactlyOnceWith(error);
expect(processor.run).not.toHaveBeenCalled();
expect(drain).not.toHaveBeenCalled();
expect(lifecycle.getStatus()).toMatchObject({ state: 'stopping', paused: true, lastError: error.message });
});
@@ -6639,3 +6639,124 @@ for (const width of [1200, 390]) {
expect(state.operations.filter((op) => op === 'messages.respond')).toHaveLength(0);
});
}
for (const viewport of [
{ name: 'desktop', width: 1280, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
]) {
for (const target of [
{ id: 'finance', chunk: 'NationStratFinanView', path: '/nation/finance' },
{ id: 'nation-cities', chunk: 'NationCitiesView', path: '/nation/cities' },
]) {
test(`recovers a stalled and failed ${target.id} navigation on ${viewport.name}`, async ({
page,
}, testInfo) => {
test.skip(!productionBundle, 'Tests actual production dynamic import failure.');
await page.setViewportSize(viewport);
const state: NavigationFixture = {
officerLevel: 12,
permission: 4,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
const ops = operationNames(route);
if (!ops.some((op) => ['nation.getStratFinan', 'nation.getCityOverview'].includes(op))) {
await route.fallback();
return;
}
const result = ops.map((op) =>
response(
op === 'nation.getStratFinan'
? {
editable: true,
nationMsg: '',
scoutMsg: '',
nationId: 1,
officerLevel: 12,
year: 185,
month: 1,
nationsList: [],
gold: 1000,
rice: 1000,
income: { gold: { city: 100, war: 0 }, rice: { city: 100, wall: 0 } },
outcome: 0,
policy: { rate: 20, bill: 100, secretLimit: 3, blockScout: false, blockWar: false },
warSettingCnt: { remain: 5, inc: 2, max: 10 },
}
: {
me: { officerLevel: 12 },
nation: { name: '검증국', color: '#008000' },
cities: [],
generals: [],
}
)
);
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify(result.length === 1 ? result[0] : result),
});
});
await page.goto('./');
const link = page.locator(`a[data-navigation-id="${target.id}"]:visible`).first();
await expect(link).toBeVisible();
let release: () => void = () => {};
const hold = new Promise<void>((resolve) => {
release = resolve;
});
let blocked = false;
await page.route(`**/${target.chunk}-*.js`, async (route) => {
if (blocked) {
await route.continue();
return;
}
blocked = true;
await hold;
await route.abort('failed');
});
const pageErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await link.click();
const notice = page.getByTestId('game-navigation-notice');
await expect(notice).toContainText('화면을 여는 중');
await expect(notice).toContainText('시간이 걸리고', { timeout: 12_000 });
expect(new URL(page.url()).pathname).toBe(`${basePath}/`);
release();
await expect(notice).toContainText('화면을 불러오지 못했습니다');
await expect(notice.locator('a')).toHaveAttribute('href', `${basePath}${target.path}`);
await page.evaluate(() => document.fonts.ready);
const geometry = await notice.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
position: style.position,
color: style.color,
background: style.backgroundColor,
};
});
expect(geometry.x).toBeGreaterThanOrEqual(0);
expect(geometry.x + geometry.width).toBeLessThanOrEqual(viewport.width);
await page.screenshot({ path: testInfo.outputPath('navigation-failed.png') });
await writeFile(
testInfo.outputPath('navigation.json'),
JSON.stringify({ geometry, pageErrors, url: page.url(), viewport })
);
await writeFile(testInfo.outputPath('navigation.html'), await page.content());
await notice.locator('a').click();
await expect(page).toHaveURL(new RegExp(`${target.path}$`));
await expect(notice).toBeHidden();
await expect(page.locator('main')).toContainText(target.id === 'finance' ? '내무부' : '세 력 도 시');
expect(pageErrors).toEqual([]);
await page.screenshot({ path: testInfo.outputPath('navigation-recovered.png') });
});
}
}
+2
View File
@@ -3,6 +3,7 @@ import { useClockDisplayRefresh } from './composables/useClockDisplayRefresh';
import { RouterView } from 'vue-router';
import GameServerConnectionNotice from './components/ui/GameServerConnectionNotice.vue';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import GameNavigationNotice from './components/ui/GameNavigationNotice.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice();
@@ -13,6 +14,7 @@ useClockDisplayRefresh();
<RouterView />
<GameServerConnectionNotice />
<GameFeedbackLayer />
<GameNavigationNotice />
</template>
<style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { routeNavigation } from '../../utils/routeNavigation';
</script>
<template>
<aside
v-if="routeNavigation.state !== 'idle'"
class="game-navigation-notice"
role="status"
aria-live="polite"
data-testid="game-navigation-notice"
>
<span v-if="routeNavigation.state === 'loading'">화면을 여는 중입니다.</span>
<template v-else>
<span v-if="routeNavigation.state === 'failed'">화면을 불러오지 못했습니다.</span>
<span v-else>화면을 여는 시간이 걸리고 있습니다.</span>
<a :href="routeNavigation.href"> 페이지 다시 열기</a>
</template>
</aside>
</template>
<style scoped>
.game-navigation-notice {
position: fixed;
z-index: 2050;
bottom: max(16px, env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
width: max-content;
max-width: calc(100vw - 24px);
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 16px;
padding: 12px 16px;
border: 1px solid #8a7765;
border-radius: 6px;
background: #251b15;
color: #fff;
}
.game-navigation-notice a {
color: #ffd59a;
text-decoration: underline;
}
</style>
+3
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
import { installRouteNavigation } from '../utils/routeNavigation';
const MainView = () => import('../views/MainView.vue');
const PublicView = () => import('../views/PublicView.vue');
@@ -389,6 +390,8 @@ const router = createRouter({
routes,
});
installRouteNavigation(router);
router.beforeEach(async (to) => {
const session = useSessionStore();
@@ -0,0 +1,35 @@
import { reactive } from 'vue';
import type { Router } from 'vue-router';
export const routeNavigation = reactive({ href: '', state: 'idle' as 'idle' | 'loading' | 'slow' | 'failed' });
export const installRouteNavigation = (router: Router): void => {
let pendingPath = '';
let visibleTimer: ReturnType<typeof setTimeout> | undefined;
let slowTimer: ReturnType<typeof setTimeout> | undefined;
const clearTimers = () => {
clearTimeout(visibleTimer);
clearTimeout(slowTimer);
};
router.beforeEach((to) => {
clearTimers();
pendingPath = to.fullPath;
routeNavigation.href = router.resolve(to).href;
routeNavigation.state = 'idle';
visibleTimer = setTimeout(() => (routeNavigation.state = 'loading'), 350);
slowTimer = setTimeout(() => (routeNavigation.state = 'slow'), 10_000);
});
router.afterEach((to) => {
if (to.fullPath !== pendingPath) return;
clearTimers();
routeNavigation.state = 'idle';
pendingPath = '';
});
router.onError((_error, to) => {
if (to.fullPath !== pendingPath) return;
clearTimers();
// 실패한 dynamic import는 같은 탭에서 캐시된다. RouterLink 재클릭 대신
// 원래 목적지의 문서를 새로 받아 모듈 캐시와 세션 초기화를 다시 시작한다.
routeNavigation.state = 'failed';
});
};