perf: 프로필 프런트엔드 자산을 커밋 단위로 공유한다
정적 운영 빌드에서 프로필별 base와 API 설정을 런타임 JSON으로 분리하고, 공용 Vite 번들과 sourcemap을 한 번만 생성·게시한다. Preview와 기존 정적 artifact의 전환 호환 경계는 유지한다.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { onBeforeUnmount, onMounted } from 'vue';
|
import { onBeforeUnmount, onMounted } from 'vue';
|
||||||
import { createDeploymentVersionChecker } from '../config/deploymentVersion';
|
import { createDeploymentVersionChecker } from '../config/deploymentVersion';
|
||||||
import { useGameFeedback } from './useGameFeedback';
|
import { useGameFeedback } from './useGameFeedback';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
const pollIntervalMs = 60_000;
|
const pollIntervalMs = 60_000;
|
||||||
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
|
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
|
||||||
@@ -14,8 +15,8 @@ const resolveSessionStorage = (): Pick<Storage, 'getItem' | 'setItem'> | undefin
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useDeploymentVersionNotice = (): void => {
|
export const useDeploymentVersionNotice = (): void => {
|
||||||
const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
|
const currentCommitSha = gameFrontendRuntimeConfig.buildCommitSha;
|
||||||
const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
|
const versionUrl = `${gameFrontendRuntimeConfig.appBasePath}deployment-version.json`;
|
||||||
const { info: showInfoToast } = useGameFeedback();
|
const { info: showInfoToast } = useGameFeedback();
|
||||||
const checker = createDeploymentVersionChecker({
|
const checker = createDeploymentVersionChecker({
|
||||||
currentCommitSha,
|
currentCommitSha,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
export const GAME_FRONTEND_RUNTIME_CONFIG_ID = 'sammo-runtime-config';
|
||||||
|
|
||||||
|
export interface GameFrontendRuntimeConfig {
|
||||||
|
version: 1;
|
||||||
|
profile?: string;
|
||||||
|
profileName?: string;
|
||||||
|
appBasePath: string;
|
||||||
|
gameApiUrl: string;
|
||||||
|
gameSseUrl: string;
|
||||||
|
gatewayApiUrl: string;
|
||||||
|
gatewayWebUrl: string;
|
||||||
|
buildCommitSha: string;
|
||||||
|
assetReleaseId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeConfigEnvironment {
|
||||||
|
VITE_APP_BASE_PATH?: string;
|
||||||
|
VITE_GAME_API_URL?: string;
|
||||||
|
VITE_GAME_SSE_URL?: string;
|
||||||
|
VITE_GAME_PROFILE?: string;
|
||||||
|
VITE_GATEWAY_API_URL?: string;
|
||||||
|
VITE_GATEWAY_WEB_URL?: string;
|
||||||
|
VITE_BUILD_COMMIT_SHA?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBasePath = (value: string | undefined): string => {
|
||||||
|
const normalized = value?.trim().replace(/^\/+|\/+$/gu, '') ?? '';
|
||||||
|
return normalized ? `/${normalized}/` : '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const nonEmpty = (value: unknown): string | undefined =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
|
|
||||||
|
const readEmbeddedConfig = (documentValue: Pick<Document, 'getElementById'> | undefined): Record<string, unknown> => {
|
||||||
|
const source = documentValue?.getElementById(GAME_FRONTEND_RUNTIME_CONFIG_ID)?.textContent?.trim();
|
||||||
|
if (!source) return {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(source);
|
||||||
|
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveGameFrontendRuntimeConfig = (
|
||||||
|
documentValue: Pick<Document, 'getElementById'> | undefined,
|
||||||
|
env: RuntimeConfigEnvironment
|
||||||
|
): GameFrontendRuntimeConfig => {
|
||||||
|
const embedded = readEmbeddedConfig(documentValue);
|
||||||
|
const appBasePath = normalizeBasePath(nonEmpty(embedded.appBasePath) ?? env.VITE_APP_BASE_PATH);
|
||||||
|
const profile = nonEmpty(embedded.profile) ?? nonEmpty(env.VITE_GAME_PROFILE);
|
||||||
|
const profileName = nonEmpty(embedded.profileName);
|
||||||
|
const assetReleaseId = nonEmpty(embedded.assetReleaseId);
|
||||||
|
return Object.freeze({
|
||||||
|
version: 1,
|
||||||
|
...(profile ? { profile } : {}),
|
||||||
|
...(profileName ? { profileName } : {}),
|
||||||
|
appBasePath,
|
||||||
|
gameApiUrl: nonEmpty(embedded.gameApiUrl) ?? nonEmpty(env.VITE_GAME_API_URL) ?? `${appBasePath}api/trpc`,
|
||||||
|
gameSseUrl: nonEmpty(embedded.gameSseUrl) ?? nonEmpty(env.VITE_GAME_SSE_URL) ?? `${appBasePath}api/events`,
|
||||||
|
gatewayApiUrl: nonEmpty(embedded.gatewayApiUrl) ?? nonEmpty(env.VITE_GATEWAY_API_URL) ?? '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: nonEmpty(embedded.gatewayWebUrl) ?? nonEmpty(env.VITE_GATEWAY_WEB_URL) ?? '/gateway/',
|
||||||
|
buildCommitSha: nonEmpty(embedded.buildCommitSha) ?? nonEmpty(env.VITE_BUILD_COMMIT_SHA) ?? 'unknown',
|
||||||
|
...(assetReleaseId ? { assetReleaseId } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gameFrontendRuntimeConfig = resolveGameFrontendRuntimeConfig(
|
||||||
|
typeof document === 'undefined' ? undefined : document,
|
||||||
|
import.meta.env ?? {}
|
||||||
|
);
|
||||||
Vendored
+1
@@ -8,6 +8,7 @@ declare module '*.vue' {
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_BASE_PATH?: string;
|
readonly VITE_APP_BASE_PATH?: string;
|
||||||
|
readonly VITE_ASSET_BASE_PATH?: string;
|
||||||
readonly VITE_GATEWAY_API_URL?: string;
|
readonly VITE_GATEWAY_API_URL?: string;
|
||||||
readonly VITE_GAME_API_URL?: string;
|
readonly VITE_GAME_API_URL?: string;
|
||||||
readonly VITE_GAME_SSE_URL?: string;
|
readonly VITE_GAME_SSE_URL?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -384,7 +385,7 @@ const routes = [
|
|||||||
] satisfies RouteRecordRaw[];
|
] satisfies RouteRecordRaw[];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(gameFrontendRuntimeConfig.appBasePath),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../
|
|||||||
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||||
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
|
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
|
||||||
import { markGameServerContact } from '../utils/gameServerActivity';
|
import { markGameServerContact } from '../utils/gameServerActivity';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
@@ -1026,7 +1027,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
||||||
|
|
||||||
const buildRealtimeUrl = (token: string): string => {
|
const buildRealtimeUrl = (token: string): string => {
|
||||||
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
|
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||||
const url = new URL(base, window.location.origin);
|
const url = new URL(base, window.location.origin);
|
||||||
url.searchParams.set('token', token);
|
url.searchParams.set('token', token);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
|
|||||||
import { takeGameSessionTransfer, type GameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
|
import { takeGameSessionTransfer, type GameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
|
||||||
import { gatewayTrpc } from '../utils/gatewayTrpc';
|
import { gatewayTrpc } from '../utils/gatewayTrpc';
|
||||||
import { trpc as gameTrpc } from '../utils/trpc';
|
import { trpc as gameTrpc } from '../utils/trpc';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
export type SessionStatus = 'unknown' | 'public' | 'authed' | 'general';
|
export type SessionStatus = 'unknown' | 'public' | 'authed' | 'general';
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.setGameToken(storedGameToken);
|
this.setGameToken(storedGameToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE;
|
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? gameFrontendRuntimeConfig.profile;
|
||||||
if (storedProfile && storedProfile !== this.profile) {
|
if (storedProfile && storedProfile !== this.profile) {
|
||||||
this.setProfile(storedProfile);
|
this.setProfile(storedProfile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
|
|||||||
import { structurallyShare } from '../utils/structuralShare';
|
import { structurallyShare } from '../utils/structuralShare';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { useSessionStore } from './session';
|
import { useSessionStore } from './session';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
type BettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
|
type BettingSummary = Awaited<ReturnType<typeof trpc.tournament.getBettingSummary.query>>;
|
||||||
@@ -136,7 +137,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
|||||||
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
|
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
|
||||||
};
|
};
|
||||||
const buildRealtimeUrl = (token: string): string => {
|
const buildRealtimeUrl = (token: string): string => {
|
||||||
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
|
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||||
const url = new URL(base, window.location.origin);
|
const url = new URL(base, window.location.origin);
|
||||||
url.searchParams.set('token', token);
|
url.searchParams.set('token', token);
|
||||||
url.searchParams.set('scope', 'tournament');
|
url.searchParams.set('scope', 'tournament');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
|
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
|
||||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
const getSessionToken = (): string | null => {
|
const getSessionToken = (): string | null => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -13,7 +14,7 @@ const getSessionToken = (): string | null => {
|
|||||||
export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
|
export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
|
||||||
links: [
|
links: [
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
|
url: gameFrontendRuntimeConfig.gatewayApiUrl,
|
||||||
...trpcJsonBodyHttpClientOptions,
|
...trpcJsonBodyHttpClientOptions,
|
||||||
headers() {
|
headers() {
|
||||||
const token = getSessionToken();
|
const token = getSessionToken();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_USER_ICON_PUBLIC_URL,
|
DEFAULT_USER_ICON_PUBLIC_URL,
|
||||||
externalizeLegacyImageUrl,
|
externalizeLegacyImageUrl,
|
||||||
} from './imageAssets.ts';
|
} from './imageAssets.ts';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts';
|
||||||
|
|
||||||
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
|
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
|
||||||
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL;
|
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL;
|
||||||
@@ -68,7 +69,7 @@ export const resolveMessageGeneralIconUrl = (
|
|||||||
if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
|
if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
return `${import.meta.env.BASE_URL}${normalized.replace(/^\/+/u, '')}`;
|
return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDefaultGeneralIcon = (event: Event): void => {
|
export const useDefaultGeneralIcon = (event: Event): void => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTranspo
|
|||||||
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
|
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
|
||||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||||
import type { AppRouter } from '@sammo-ts/game-api';
|
import type { AppRouter } from '@sammo-ts/game-api';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
||||||
import { markGameServerContact } from './gameServerActivity';
|
import { markGameServerContact } from './gameServerActivity';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ const getGameToken = (): string | null => {
|
|||||||
export const trpc = createTRPCProxyClient<AppRouter>({
|
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||||
links: [
|
links: [
|
||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
url: gameFrontendRuntimeConfig.gameApiUrl,
|
||||||
...trpcJsonBodyHttpClientOptions,
|
...trpcJsonBodyHttpClientOptions,
|
||||||
async fetch(input, init) {
|
async fetch(input, init) {
|
||||||
const result = await globalThis.fetch(input, init);
|
const result = await globalThis.fetch(input, init);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_WEB_URL || '/gateway/';
|
const gatewayUrl = gameFrontendRuntimeConfig.gatewayWebUrl;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.location.replace(gatewayUrl);
|
window.location.replace(gatewayUrl);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useMainDashboardStore } from '../stores/mainDashboard';
|
|||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import type { CommandPatternEntry } from '../components/command/types';
|
import type { CommandPatternEntry } from '../components/command/types';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
import {
|
import {
|
||||||
loadMobileMainPanelOrder,
|
loadMobileMainPanelOrder,
|
||||||
MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT,
|
MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT,
|
||||||
@@ -45,9 +46,9 @@ const isMobile = useMediaQuery('(max-width: 939.98px)');
|
|||||||
const npcMode = ref(0);
|
const npcMode = ref(0);
|
||||||
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
|
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
|
||||||
const versionDialog = ref<HTMLDialogElement | null>(null);
|
const versionDialog = ref<HTMLDialogElement | null>(null);
|
||||||
const buildCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() || 'unknown';
|
const buildCommitSha = gameFrontendRuntimeConfig.buildCommitSha;
|
||||||
const mobilePanelOrder = ref(loadMobileMainPanelOrder());
|
const mobilePanelOrder = ref(loadMobileMainPanelOrder());
|
||||||
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
|
const navigationUrl = gameFrontendRuntimeConfig.gatewayApiUrl.replace(/\/trpc\/?$/u, '/navigation');
|
||||||
|
|
||||||
const reloadMobilePanelOrder = () => {
|
const reloadMobilePanelOrder = () => {
|
||||||
mobilePanelOrder.value = loadMobileMainPanelOrder();
|
mobilePanelOrder.value = loadMobileMainPanelOrder();
|
||||||
@@ -188,7 +189,7 @@ const requestManualRefresh = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moveLobby = () => {
|
const moveLobby = () => {
|
||||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
window.location.replace(gameFrontendRuntimeConfig.gatewayWebUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveQuick = (item: QuickNavigationItem) => {
|
const moveQuick = (item: QuickNavigationItem) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useSessionStore } from '../stores/session';
|
|||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
|
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
|
||||||
|
|
||||||
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
||||||
const { error: showErrorToast, showDialog } = useGameFeedback();
|
const { error: showErrorToast, showDialog } = useGameFeedback();
|
||||||
@@ -308,7 +309,7 @@ const dieOnPrestart = async () => {
|
|||||||
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
|
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
|
||||||
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
session.leaveGame();
|
session.leaveGame();
|
||||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
window.location.replace(gameFrontendRuntimeConfig.gatewayWebUrl);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const code = asRecord(asRecord(cause).data).code;
|
const code = asRecord(asRecord(cause).data).code;
|
||||||
if (code !== 'TIMEOUT') {
|
if (code !== 'TIMEOUT') {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
|
||||||
|
import { resolveGameFrontendRuntimeConfig } from '../src/config/runtimeConfig.ts';
|
||||||
|
|
||||||
|
const documentWithConfig = (value: string | null): Pick<Document, 'getElementById'> => ({
|
||||||
|
getElementById: () => (value === null ? null : ({ textContent: value } as HTMLElement)),
|
||||||
|
});
|
||||||
|
|
||||||
|
void describe('game frontend runtime config', () => {
|
||||||
|
void it('prefers the embedded profile wrapper config over build-time fallbacks', () => {
|
||||||
|
const config = resolveGameFrontendRuntimeConfig(
|
||||||
|
documentWithConfig(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profile: 'pya',
|
||||||
|
profileName: 'pya:default',
|
||||||
|
appBasePath: '/pya',
|
||||||
|
gameApiUrl: '/pya/api/trpc',
|
||||||
|
gameSseUrl: '/pya/api/events',
|
||||||
|
gatewayApiUrl: '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: '/gateway/',
|
||||||
|
buildCommitSha: 'a'.repeat(40),
|
||||||
|
assetReleaseId: `${'a'.repeat(40)}-${'b'.repeat(16)}`,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
VITE_APP_BASE_PATH: '/che',
|
||||||
|
VITE_GAME_API_URL: '/che/api/trpc',
|
||||||
|
VITE_GAME_SSE_URL: '/che/api/events',
|
||||||
|
VITE_GAME_PROFILE: 'che',
|
||||||
|
VITE_BUILD_COMMIT_SHA: 'c'.repeat(40),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(config.profile, 'pya');
|
||||||
|
assert.equal(config.appBasePath, '/pya/');
|
||||||
|
assert.equal(config.gameApiUrl, '/pya/api/trpc');
|
||||||
|
assert.equal(config.gameSseUrl, '/pya/api/events');
|
||||||
|
assert.equal(config.buildCommitSha, 'a'.repeat(40));
|
||||||
|
assert.equal(config.assetReleaseId, `${'a'.repeat(40)}-${'b'.repeat(16)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('keeps preview and local development compatible through Vite environment fallbacks', () => {
|
||||||
|
const config = resolveGameFrontendRuntimeConfig(documentWithConfig(null), {
|
||||||
|
VITE_APP_BASE_PATH: '/hwe',
|
||||||
|
VITE_GAME_API_URL: '/hwe/api/trpc',
|
||||||
|
VITE_GAME_SSE_URL: '/hwe/api/events',
|
||||||
|
VITE_GAME_PROFILE: 'hwe',
|
||||||
|
VITE_GATEWAY_API_URL: '/gateway/api/trpc',
|
||||||
|
VITE_GATEWAY_WEB_URL: '/gateway/',
|
||||||
|
VITE_BUILD_COMMIT_SHA: 'd'.repeat(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(config, {
|
||||||
|
version: 1,
|
||||||
|
profile: 'hwe',
|
||||||
|
appBasePath: '/hwe/',
|
||||||
|
gameApiUrl: '/hwe/api/trpc',
|
||||||
|
gameSseUrl: '/hwe/api/events',
|
||||||
|
gatewayApiUrl: '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: '/gateway/',
|
||||||
|
buildCommitSha: 'd'.repeat(40),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void it('falls back safely when the embedded script is malformed', () => {
|
||||||
|
const config = resolveGameFrontendRuntimeConfig(documentWithConfig('{'), {});
|
||||||
|
|
||||||
|
assert.equal(config.appBasePath, '/');
|
||||||
|
assert.equal(config.gameApiUrl, '/api/trpc');
|
||||||
|
assert.equal(config.gameSseUrl, '/api/events');
|
||||||
|
assert.equal(config.buildCommitSha, 'unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,31 @@ void describe('game frontend Vite config', () => {
|
|||||||
assert.equal(loaded?.config.build?.sourcemap, true);
|
assert.equal(loaded?.config.build?.sourcemap, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void it('allows a profile-neutral relative asset base without changing the application base fallback', async () => {
|
||||||
|
const previousAssetBasePath = process.env.VITE_ASSET_BASE_PATH;
|
||||||
|
const previousAppBasePath = process.env.VITE_APP_BASE_PATH;
|
||||||
|
process.env.VITE_ASSET_BASE_PATH = './';
|
||||||
|
process.env.VITE_APP_BASE_PATH = '/che';
|
||||||
|
try {
|
||||||
|
const configPath = path.resolve(import.meta.dirname, '../vite.config.ts');
|
||||||
|
const loaded = await loadConfigFromFile(
|
||||||
|
{ command: 'build', mode: 'production' },
|
||||||
|
configPath,
|
||||||
|
path.dirname(configPath),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'runner'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(loaded?.config.base, './');
|
||||||
|
} finally {
|
||||||
|
if (previousAssetBasePath === undefined) delete process.env.VITE_ASSET_BASE_PATH;
|
||||||
|
else process.env.VITE_ASSET_BASE_PATH = previousAssetBasePath;
|
||||||
|
if (previousAppBasePath === undefined) delete process.env.VITE_APP_BASE_PATH;
|
||||||
|
else process.env.VITE_APP_BASE_PATH = previousAppBasePath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
void it('uses the deployment-pinned full commit SHA as the displayed build version', async () => {
|
void it('uses the deployment-pinned full commit SHA as the displayed build version', async () => {
|
||||||
const commitSha = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
|
const commitSha = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
|
||||||
const previousCommitSha = process.env.VITE_BUILD_COMMIT_SHA;
|
const previousCommitSha = process.env.VITE_BUILD_COMMIT_SHA;
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const createDeploymentVersionPlugin = (buildCommitSha: string): Plugin =>
|
|||||||
|
|
||||||
const normalizeBasePath = (value: string | undefined): string => {
|
const normalizeBasePath = (value: string | undefined): string => {
|
||||||
const pathValue = (value ?? '/').trim();
|
const pathValue = (value ?? '/').trim();
|
||||||
|
if (pathValue === './') {
|
||||||
|
return './';
|
||||||
|
}
|
||||||
if (!pathValue || pathValue === '/') {
|
if (!pathValue || pathValue === '/') {
|
||||||
return '/';
|
return '/';
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = mergeViteEnv(loadEnv(mode, process.cwd(), ''), process.env);
|
const env = mergeViteEnv(loadEnv(mode, process.cwd(), ''), process.env);
|
||||||
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
|
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
|
||||||
return {
|
return {
|
||||||
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
|
base: normalizeBasePath(env.VITE_ASSET_BASE_PATH ?? env.VITE_APP_BASE_PATH),
|
||||||
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
|
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
|
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
|
||||||
|
|||||||
@@ -19,9 +19,26 @@ export interface StagedFrontendArtifact {
|
|||||||
manifest: FrontendArtifactManifest;
|
manifest: FrontendArtifactManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileFrontendRuntimeConfig {
|
||||||
|
version: 1;
|
||||||
|
profile: string;
|
||||||
|
profileName: string;
|
||||||
|
appBasePath: string;
|
||||||
|
gameApiUrl: string;
|
||||||
|
gameSseUrl: string;
|
||||||
|
gatewayApiUrl: string;
|
||||||
|
gatewayWebUrl: string;
|
||||||
|
buildCommitSha: string;
|
||||||
|
assetReleaseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHARED_GAME_FRONTEND_KEY = 'game-assets';
|
||||||
|
export const GAME_FRONTEND_RUNTIME_CONFIG_ID = 'sammo-runtime-config';
|
||||||
|
|
||||||
const MANIFEST_FILE = '.sammo-artifact.json';
|
const MANIFEST_FILE = '.sammo-artifact.json';
|
||||||
const FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
|
const FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
|
||||||
const COMMIT_SHA = /^[0-9a-f]{40,64}$/iu;
|
const COMMIT_SHA = /^[0-9a-f]{40,64}$/iu;
|
||||||
|
const PUBLIC_ASSET_BASE = /^\/[0-9A-Za-z/_-]*$/u;
|
||||||
|
|
||||||
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
|
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
|
||||||
const normalized = value?.trim().toLowerCase();
|
const normalized = value?.trim().toLowerCase();
|
||||||
@@ -84,7 +101,9 @@ const buildDigest = async (sourceRoot: string, files: string[]): Promise<string>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readManifest = async (releasePath: string): Promise<FrontendArtifactManifest> => {
|
const readManifest = async (releasePath: string): Promise<FrontendArtifactManifest> => {
|
||||||
const raw = JSON.parse(await fs.readFile(path.join(releasePath, MANIFEST_FILE), 'utf8')) as Partial<FrontendArtifactManifest>;
|
const raw = JSON.parse(
|
||||||
|
await fs.readFile(path.join(releasePath, MANIFEST_FILE), 'utf8')
|
||||||
|
) as Partial<FrontendArtifactManifest>;
|
||||||
if (
|
if (
|
||||||
raw.version !== 1 ||
|
raw.version !== 1 ||
|
||||||
typeof raw.frontendKey !== 'string' ||
|
typeof raw.frontendKey !== 'string' ||
|
||||||
@@ -102,6 +121,43 @@ const readManifest = async (releasePath: string): Promise<FrontendArtifactManife
|
|||||||
const isMissing = (error: unknown): boolean =>
|
const isMissing = (error: unknown): boolean =>
|
||||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT';
|
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||||
|
|
||||||
|
const escapeEmbeddedJson = (value: unknown): string =>
|
||||||
|
JSON.stringify(value)
|
||||||
|
.replaceAll('&', '\\u0026')
|
||||||
|
.replaceAll('<', '\\u003c')
|
||||||
|
.replaceAll('>', '\\u003e')
|
||||||
|
.replaceAll('\u2028', '\\u2028')
|
||||||
|
.replaceAll('\u2029', '\\u2029');
|
||||||
|
|
||||||
|
export const renderProfileFrontendIndex = (options: {
|
||||||
|
sharedIndexHtml: string;
|
||||||
|
sharedReleaseId: string;
|
||||||
|
sharedAssetPublicBase: string;
|
||||||
|
runtimeConfig: ProfileFrontendRuntimeConfig;
|
||||||
|
}): string => {
|
||||||
|
const publicBase = options.sharedAssetPublicBase.trim().replace(/\/+$/u, '');
|
||||||
|
if (!PUBLIC_ASSET_BASE.test(publicBase) || !publicBase) {
|
||||||
|
throw new Error(`Invalid shared frontend asset public base: ${options.sharedAssetPublicBase}`);
|
||||||
|
}
|
||||||
|
if (options.runtimeConfig.assetReleaseId !== options.sharedReleaseId) {
|
||||||
|
throw new Error('Profile frontend runtime config does not match the shared asset release.');
|
||||||
|
}
|
||||||
|
const releaseBase = `${publicBase}/${options.sharedReleaseId}`;
|
||||||
|
const rewritten = options.sharedIndexHtml.replace(
|
||||||
|
/\b(src|href)="\.\/(assets\/[^"?#]+(?:[?#][^"]*)?)"/gu,
|
||||||
|
(_match, attribute: string, assetPath: string) => `${attribute}="${releaseBase}/${assetPath}"`
|
||||||
|
);
|
||||||
|
if (rewritten === options.sharedIndexHtml || /(?:src|href)="\.\/assets\//u.test(rewritten)) {
|
||||||
|
throw new Error('Shared frontend index does not contain only rewritable relative asset URLs.');
|
||||||
|
}
|
||||||
|
const moduleScript = rewritten.search(/<script\b[^>]*\btype="module"/iu);
|
||||||
|
if (moduleScript < 0) {
|
||||||
|
throw new Error('Shared frontend index is missing its module script.');
|
||||||
|
}
|
||||||
|
const runtimeScript = ` <script id="${GAME_FRONTEND_RUNTIME_CONFIG_ID}" type="application/json">${escapeEmbeddedJson(options.runtimeConfig)}</script>\n`;
|
||||||
|
return `${rewritten.slice(0, moduleScript)}${runtimeScript}${rewritten.slice(moduleScript)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export class FrontendArtifactManager {
|
export class FrontendArtifactManager {
|
||||||
readonly root: string;
|
readonly root: string;
|
||||||
|
|
||||||
@@ -170,7 +226,12 @@ export class FrontendArtifactManager {
|
|||||||
try {
|
try {
|
||||||
await fs.rename(stagingPath, releasePath);
|
await fs.rename(stagingPath, releasePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissing(error) && error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
|
if (
|
||||||
|
!isMissing(error) &&
|
||||||
|
error instanceof Error &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === 'EEXIST'
|
||||||
|
) {
|
||||||
const existing = await readManifest(releasePath);
|
const existing = await readManifest(releasePath);
|
||||||
if (existing.digest !== digest || existing.commitSha !== commitSha) throw error;
|
if (existing.digest !== digest || existing.commitSha !== commitSha) throw error;
|
||||||
} else {
|
} else {
|
||||||
@@ -183,6 +244,51 @@ export class FrontendArtifactManager {
|
|||||||
return { releaseId, releasePath, manifest };
|
return { releaseId, releasePath, manifest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stageProfileWrapper(options: {
|
||||||
|
frontendKey: string;
|
||||||
|
sharedArtifact: StagedFrontendArtifact;
|
||||||
|
sharedAssetPublicBase: string;
|
||||||
|
runtimeConfig: Omit<ProfileFrontendRuntimeConfig, 'buildCommitSha' | 'assetReleaseId'>;
|
||||||
|
}): Promise<StagedFrontendArtifact> {
|
||||||
|
if (options.runtimeConfig.profile !== options.frontendKey) {
|
||||||
|
throw new Error('Profile frontend wrapper key does not match its runtime profile.');
|
||||||
|
}
|
||||||
|
await fs.mkdir(this.root, { recursive: true, mode: 0o755 });
|
||||||
|
const sourceRoot = await fs.mkdtemp(path.join(this.root, '.profile-wrapper-'));
|
||||||
|
try {
|
||||||
|
const runtimeConfig: ProfileFrontendRuntimeConfig = {
|
||||||
|
...options.runtimeConfig,
|
||||||
|
buildCommitSha: options.sharedArtifact.manifest.commitSha,
|
||||||
|
assetReleaseId: options.sharedArtifact.releaseId,
|
||||||
|
};
|
||||||
|
const sharedIndexHtml = await fs.readFile(
|
||||||
|
path.join(options.sharedArtifact.releasePath, 'index.html'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const wrapperIndexHtml = renderProfileFrontendIndex({
|
||||||
|
sharedIndexHtml,
|
||||||
|
sharedReleaseId: options.sharedArtifact.releaseId,
|
||||||
|
sharedAssetPublicBase: options.sharedAssetPublicBase,
|
||||||
|
runtimeConfig,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(sourceRoot, 'index.html'), wrapperIndexHtml, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(options.sharedArtifact.releasePath, 'deployment-version.json'),
|
||||||
|
path.join(sourceRoot, 'deployment-version.json')
|
||||||
|
);
|
||||||
|
return await this.stage({
|
||||||
|
frontendKey: options.frontendKey,
|
||||||
|
sourceRoot,
|
||||||
|
commitSha: options.sharedArtifact.manifest.commitSha,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(sourceRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async readCurrentReleaseId(frontendKey: string): Promise<string | null> {
|
async readCurrentReleaseId(frontendKey: string): Promise<string | null> {
|
||||||
const frontendRoot = this.frontendRoot(frontendKey);
|
const frontendRoot = this.frontendRoot(frontendKey);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.
|
|||||||
import {
|
import {
|
||||||
FrontendArtifactManager,
|
FrontendArtifactManager,
|
||||||
resolveFrontendServeMode,
|
resolveFrontendServeMode,
|
||||||
|
SHARED_GAME_FRONTEND_KEY,
|
||||||
type FrontendServeMode,
|
type FrontendServeMode,
|
||||||
|
type StagedFrontendArtifact,
|
||||||
} from './frontendArtifactManager.js';
|
} from './frontendArtifactManager.js';
|
||||||
|
|
||||||
export interface GatewayProcessConfig {
|
export interface GatewayProcessConfig {
|
||||||
@@ -573,6 +575,9 @@ const sanitizeArtifactName = (value: string): string => value.replace(/[^0-9A-Za
|
|||||||
const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
|
const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
|
||||||
path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend');
|
path.join(workspaceRoot, '.release-dist', sanitizeArtifactName(profileName), 'game-frontend');
|
||||||
|
|
||||||
|
const buildSharedProfileFrontendOutDir = (workspaceRoot: string): string =>
|
||||||
|
path.join(workspaceRoot, 'app', 'game-frontend', '.release-build');
|
||||||
|
|
||||||
export const buildProfileFrontendCommands = (
|
export const buildProfileFrontendCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
||||||
@@ -609,6 +614,37 @@ export const buildProfileFrontendCommands = (
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildSharedProfileFrontendCommands = (
|
||||||
|
workspaceRoot: string,
|
||||||
|
buildCommitSha: string,
|
||||||
|
env?: Record<string, string>,
|
||||||
|
cacheAnchorRoot: string = workspaceRoot
|
||||||
|
): BuildCommand[] => {
|
||||||
|
if (!/^[0-9a-f]{40,64}$/iu.test(buildCommitSha.trim())) {
|
||||||
|
throw new Error('Shared profile frontend build requires a full commit SHA.');
|
||||||
|
}
|
||||||
|
const sharedEnv = { ...(env ?? {}) };
|
||||||
|
for (const key of ['VITE_APP_BASE_PATH', 'VITE_GAME_API_URL', 'VITE_GAME_SSE_URL', 'VITE_GAME_PROFILE']) {
|
||||||
|
delete sharedEnv[key];
|
||||||
|
}
|
||||||
|
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
|
||||||
|
const buildEnv = sanitizeReleaseBuildEnv({
|
||||||
|
...sharedEnv,
|
||||||
|
...(profileFrontendBuildNodeOptions ? { NODE_OPTIONS: profileFrontendBuildNodeOptions } : {}),
|
||||||
|
VITE_ASSET_BASE_PATH: './',
|
||||||
|
VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(),
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
buildTurboReleaseTaskCommand(
|
||||||
|
workspaceRoot,
|
||||||
|
cacheAnchorRoot,
|
||||||
|
'build:release',
|
||||||
|
['@sammo-ts/game-frontend'],
|
||||||
|
buildEnv
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const buildWorkspaceCommands = (
|
export const buildWorkspaceCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
needsInstall: boolean,
|
needsInstall: boolean,
|
||||||
@@ -1556,13 +1592,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.processConfig.workspaceRoot,
|
this.processConfig.workspaceRoot,
|
||||||
['@sammo-ts/game-api']
|
['@sammo-ts/game-api']
|
||||||
),
|
),
|
||||||
...buildProfileFrontendCommands(
|
...(this.frontendServeMode === 'static'
|
||||||
workspace.root,
|
? buildSharedProfileFrontendCommands(
|
||||||
profile,
|
workspace.root,
|
||||||
commitSha,
|
commitSha,
|
||||||
this.processConfig.baseEnv,
|
this.processConfig.baseEnv,
|
||||||
this.processConfig.workspaceRoot
|
this.processConfig.workspaceRoot
|
||||||
),
|
)
|
||||||
|
: buildProfileFrontendCommands(
|
||||||
|
workspace.root,
|
||||||
|
profile,
|
||||||
|
commitSha,
|
||||||
|
this.processConfig.baseEnv,
|
||||||
|
this.processConfig.workspaceRoot
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
||||||
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
|
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
|
||||||
@@ -2148,13 +2191,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.processConfig.workspaceRoot
|
this.processConfig.workspaceRoot
|
||||||
),
|
),
|
||||||
...(profile
|
...(profile
|
||||||
? buildProfileFrontendCommands(
|
? this.frontendServeMode === 'static'
|
||||||
workspace.root,
|
? buildSharedProfileFrontendCommands(
|
||||||
profile,
|
workspace.root,
|
||||||
commitSha,
|
commitSha,
|
||||||
this.processConfig.baseEnv,
|
this.processConfig.baseEnv,
|
||||||
this.processConfig.workspaceRoot
|
this.processConfig.workspaceRoot
|
||||||
)
|
)
|
||||||
|
: buildProfileFrontendCommands(
|
||||||
|
workspace.root,
|
||||||
|
profile,
|
||||||
|
commitSha,
|
||||||
|
this.processConfig.baseEnv,
|
||||||
|
this.processConfig.workspaceRoot
|
||||||
|
)
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
if (operationId) {
|
if (operationId) {
|
||||||
@@ -2255,10 +2305,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
|
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
|
||||||
return resolveGatewayPostgresConfigFromEnv(
|
return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url;
|
||||||
this.processConfig.baseEnv ?? process.env,
|
|
||||||
profile.profile
|
|
||||||
).url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise<void> {
|
private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise<void> {
|
||||||
@@ -2324,6 +2371,49 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
|
||||||
|
if (!profile.buildCommitSha) {
|
||||||
|
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
|
||||||
|
}
|
||||||
|
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
|
||||||
|
const sharedSourceRoot = buildSharedProfileFrontendOutDir(runtimeWorkspace);
|
||||||
|
const sharedIndexPath = path.join(sharedSourceRoot, 'index.html');
|
||||||
|
const sharedIndexHtml = await fs.readFile(sharedIndexPath, 'utf8').catch((error: unknown) => {
|
||||||
|
if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (sharedIndexHtml?.includes('./assets/')) {
|
||||||
|
const sharedArtifact = await this.artifactManager.stage({
|
||||||
|
frontendKey: SHARED_GAME_FRONTEND_KEY,
|
||||||
|
sourceRoot: sharedSourceRoot,
|
||||||
|
commitSha: profile.buildCommitSha,
|
||||||
|
});
|
||||||
|
const baseEnv = this.processConfig.baseEnv ?? {};
|
||||||
|
return this.artifactManager.stageProfileWrapper({
|
||||||
|
frontendKey: profile.profile,
|
||||||
|
sharedArtifact,
|
||||||
|
sharedAssetPublicBase: baseEnv.FRONTEND_SHARED_ASSET_PUBLIC_PATH?.trim() || '/gateway/profile-assets',
|
||||||
|
runtimeConfig: {
|
||||||
|
version: 1,
|
||||||
|
profile: profile.profile,
|
||||||
|
profileName: profile.profileName,
|
||||||
|
appBasePath: `/${profile.profile}/`,
|
||||||
|
gameApiUrl: `/${profile.profile}/api/trpc`,
|
||||||
|
gameSseUrl: `/${profile.profile}/api/events`,
|
||||||
|
gatewayApiUrl: baseEnv.VITE_GATEWAY_API_URL?.trim() || '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: baseEnv.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.artifactManager.stage({
|
||||||
|
frontendKey: profile.profile,
|
||||||
|
sourceRoot: buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName),
|
||||||
|
commitSha: profile.buildCommitSha,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
|
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
|
||||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||||
const orderedDefinitions = [
|
const orderedDefinitions = [
|
||||||
@@ -2337,19 +2427,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const attemptedNames: string[] = [];
|
const attemptedNames: string[] = [];
|
||||||
try {
|
try {
|
||||||
const stagedArtifact =
|
const stagedArtifact =
|
||||||
this.frontendServeMode === 'static'
|
this.frontendServeMode === 'static' ? await this.stageStaticProfileFrontend(profile) : null;
|
||||||
? await (async () => {
|
|
||||||
if (!profile.buildCommitSha) {
|
|
||||||
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
|
|
||||||
}
|
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
|
|
||||||
return this.artifactManager.stage({
|
|
||||||
frontendKey: profile.profile,
|
|
||||||
sourceRoot: buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName),
|
|
||||||
commitSha: profile.buildCommitSha,
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
: null;
|
|
||||||
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
|
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
|
||||||
const obsoleteNames =
|
const obsoleteNames =
|
||||||
this.frontendServeMode === 'static' ? new Set([definitions.frontend.name]) : new Set<string>();
|
this.frontendServeMode === 'static' ? new Set([definitions.frontend.name]) : new Set<string>();
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { FrontendArtifactManager, resolveFrontendServeMode } from '../src/orchestrator/frontendArtifactManager.js';
|
import {
|
||||||
|
FrontendArtifactManager,
|
||||||
|
GAME_FRONTEND_RUNTIME_CONFIG_ID,
|
||||||
|
renderProfileFrontendIndex,
|
||||||
|
resolveFrontendServeMode,
|
||||||
|
SHARED_GAME_FRONTEND_KEY,
|
||||||
|
} from '../src/orchestrator/frontendArtifactManager.js';
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
const sha = 'a'.repeat(40);
|
const sha = 'a'.repeat(40);
|
||||||
@@ -70,4 +76,73 @@ describe('FrontendArtifactManager', () => {
|
|||||||
/symbolic link/u
|
/symbolic link/u
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('publishes one shared asset release and a small profile runtime-config wrapper', async () => {
|
||||||
|
const { source, artifacts } = await fixture();
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(source, 'index.html'),
|
||||||
|
'<!doctype html><head><script type="module" src="./assets/app-deadbeef.js"></script></head>'
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(source, 'deployment-version.json'), `${JSON.stringify({ commitSha: sha })}\n`);
|
||||||
|
const manager = new FrontendArtifactManager(artifacts);
|
||||||
|
const sharedArtifact = await manager.stage({
|
||||||
|
frontendKey: SHARED_GAME_FRONTEND_KEY,
|
||||||
|
sourceRoot: source,
|
||||||
|
commitSha: sha,
|
||||||
|
});
|
||||||
|
const wrapper = await manager.stageProfileWrapper({
|
||||||
|
frontendKey: 'pya',
|
||||||
|
sharedArtifact,
|
||||||
|
sharedAssetPublicBase: '/gateway/profile-assets',
|
||||||
|
runtimeConfig: {
|
||||||
|
version: 1,
|
||||||
|
profile: 'pya',
|
||||||
|
profileName: 'pya:default',
|
||||||
|
appBasePath: '/pya/',
|
||||||
|
gameApiUrl: '/pya/api/trpc',
|
||||||
|
gameSseUrl: '/pya/api/events',
|
||||||
|
gatewayApiUrl: '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: '/gateway/',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await manager.activate('pya', wrapper.releaseId);
|
||||||
|
|
||||||
|
const indexHtml = await fs.readFile(path.join(artifacts, 'pya', 'current', 'index.html'), 'utf8');
|
||||||
|
expect(indexHtml).toContain(`id="${GAME_FRONTEND_RUNTIME_CONFIG_ID}" type="application/json"`);
|
||||||
|
expect(indexHtml).toContain('"profile":"pya"');
|
||||||
|
expect(indexHtml).toContain(`"assetReleaseId":"${sharedArtifact.releaseId}"`);
|
||||||
|
expect(indexHtml).toContain(`src="/gateway/profile-assets/${sharedArtifact.releaseId}/assets/app-deadbeef.js"`);
|
||||||
|
expect(await fs.readdir(path.join(artifacts, 'pya', 'current'))).toEqual([
|
||||||
|
'.sammo-artifact.json',
|
||||||
|
'deployment-version.json',
|
||||||
|
'index.html',
|
||||||
|
]);
|
||||||
|
expect(await fs.readFile(path.join(sharedArtifact.releasePath, 'assets', 'app-deadbeef.js'), 'utf8')).toBe(
|
||||||
|
'console.log(1)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes script-closing runtime values before embedding JSON', () => {
|
||||||
|
const releaseId = `${sha}-${'b'.repeat(16)}`;
|
||||||
|
const rendered = renderProfileFrontendIndex({
|
||||||
|
sharedIndexHtml: '<script type="module" src="./assets/app-deadbeef.js"></script>',
|
||||||
|
sharedReleaseId: releaseId,
|
||||||
|
sharedAssetPublicBase: '/gateway/profile-assets',
|
||||||
|
runtimeConfig: {
|
||||||
|
version: 1,
|
||||||
|
profile: 'che',
|
||||||
|
profileName: 'che:default',
|
||||||
|
appBasePath: '/che/',
|
||||||
|
gameApiUrl: '/che/api/trpc?</script>',
|
||||||
|
gameSseUrl: '/che/api/events',
|
||||||
|
gatewayApiUrl: '/gateway/api/trpc',
|
||||||
|
gatewayWebUrl: '/gateway/',
|
||||||
|
buildCommitSha: sha,
|
||||||
|
assetReleaseId: releaseId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rendered).not.toContain('trpc?</script>');
|
||||||
|
expect(rendered).toContain('\\u003c/script\\u003e');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -391,7 +391,52 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes a legacy Vite process while publishing the first static artifact', async () => {
|
it('removes a legacy Vite process while publishing a shared static asset and profile wrapper', async () => {
|
||||||
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-shared-static-cutover-'));
|
||||||
|
temporaryDirectories.push(workspace);
|
||||||
|
const releaseBuild = path.join(workspace, 'app', 'game-frontend', '.release-build');
|
||||||
|
await fs.mkdir(path.join(releaseBuild, 'assets'), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(releaseBuild, 'index.html'),
|
||||||
|
'<!doctype html><head><script type="module" src="./assets/index-deadbeef.js"></script></head>'
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(releaseBuild, 'assets', 'index-deadbeef.js'), 'console.log("shared")');
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(releaseBuild, 'deployment-version.json'),
|
||||||
|
`${JSON.stringify({ commitSha: profile.buildCommitSha })}\n`
|
||||||
|
);
|
||||||
|
const artifactRoot = path.join(workspace, 'artifacts');
|
||||||
|
const staticProfile = { ...profile, status: 'RUNNING' as const, buildWorkspace: workspace };
|
||||||
|
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, undefined, {
|
||||||
|
profile: staticProfile,
|
||||||
|
frontendServeMode: 'static',
|
||||||
|
frontendArtifactRoot: artifactRoot,
|
||||||
|
activeOperationProfileNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await harness.orchestrator.reconcileNow();
|
||||||
|
|
||||||
|
expect(harness.deleted).toContain('sammo:che:2:game-frontend');
|
||||||
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
|
'sammo:che:2:game-api',
|
||||||
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:auction-worker',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
|
'sammo:che:2:tournament-worker',
|
||||||
|
]);
|
||||||
|
const indexHtml = await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8');
|
||||||
|
expect(indexHtml).toContain('id="sammo-runtime-config" type="application/json"');
|
||||||
|
expect(indexHtml).toContain('"profile":"che"');
|
||||||
|
expect(indexHtml).toContain('/gateway/profile-assets/');
|
||||||
|
const sharedReleaseRoot = path.join(artifactRoot, 'game-assets', 'releases');
|
||||||
|
const [sharedReleaseId] = await fs.readdir(sharedReleaseRoot);
|
||||||
|
expect(
|
||||||
|
await fs.readFile(path.join(sharedReleaseRoot, sharedReleaseId, 'assets', 'index-deadbeef.js'), 'utf8')
|
||||||
|
).toBe('console.log("shared")');
|
||||||
|
expect(harness.completions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy full profile artifact compatible during the static cutover', async () => {
|
||||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-'));
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-'));
|
||||||
temporaryDirectories.push(workspace);
|
temporaryDirectories.push(workspace);
|
||||||
await fs.mkdir(path.join(workspace, '.release-dist', 'che_2', 'game-frontend'), { recursive: true });
|
await fs.mkdir(path.join(workspace, '.release-dist', 'che_2', 'game-frontend'), { recursive: true });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildProfileFrontendCommands,
|
buildProfileFrontendCommands,
|
||||||
buildProfileMigrationCommand,
|
buildProfileMigrationCommand,
|
||||||
buildProcessDefinitions,
|
buildProcessDefinitions,
|
||||||
|
buildSharedProfileFrontendCommands,
|
||||||
buildWorkspaceCommands,
|
buildWorkspaceCommands,
|
||||||
planProfileReconcile,
|
planProfileReconcile,
|
||||||
resolveResetLifecycleStatus,
|
resolveResetLifecycleStatus,
|
||||||
@@ -464,3 +465,43 @@ describe('buildProfileFrontendCommands', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildSharedProfileFrontendCommands', () => {
|
||||||
|
const buildCommitSha = '0123456789abcdef0123456789abcdef01234567';
|
||||||
|
|
||||||
|
it('uses one profile-neutral relative-asset build cache key for every profile', () => {
|
||||||
|
const commands = buildSharedProfileFrontendCommands(
|
||||||
|
'/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
buildCommitSha,
|
||||||
|
{
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=1536',
|
||||||
|
PROFILE_FRONTEND_BUILD_NODE_OPTIONS: '--max-old-space-size=2048',
|
||||||
|
VITE_APP_BASE_PATH: '/che',
|
||||||
|
VITE_GAME_API_URL: '/che/api/trpc',
|
||||||
|
VITE_GAME_SSE_URL: '/che/api/events',
|
||||||
|
VITE_GAME_PROFILE: 'che',
|
||||||
|
VITE_GATEWAY_API_URL: '/gateway/api/trpc',
|
||||||
|
},
|
||||||
|
'/srv/sammo/controller'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commands).toHaveLength(1);
|
||||||
|
expect(commands[0]?.env).toMatchObject({
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=2048',
|
||||||
|
VITE_ASSET_BASE_PATH: './',
|
||||||
|
VITE_BUILD_COMMIT_SHA: buildCommitSha,
|
||||||
|
VITE_GATEWAY_API_URL: '/gateway/api/trpc',
|
||||||
|
});
|
||||||
|
expect(commands[0]?.env).not.toHaveProperty('VITE_APP_BASE_PATH');
|
||||||
|
expect(commands[0]?.env).not.toHaveProperty('VITE_GAME_API_URL');
|
||||||
|
expect(commands[0]?.env).not.toHaveProperty('VITE_GAME_SSE_URL');
|
||||||
|
expect(commands[0]?.env).not.toHaveProperty('VITE_GAME_PROFILE');
|
||||||
|
expect(commands[0]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-commit shared build version', () => {
|
||||||
|
expect(() => buildSharedProfileFrontendCommands('/srv/sammo/worktrees/main', 'main')).toThrow(
|
||||||
|
'Shared profile frontend build requires a full commit SHA.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,17 +37,21 @@ const createReleaseWorkspace = async (): Promise<string> => {
|
|||||||
components: ['game-api', 'game-engine', 'game-frontend'],
|
components: ['game-api', 'game-engine', 'game-frontend'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await fs.mkdir(path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets'), {
|
await fs.mkdir(path.join(workspace, 'app', 'game-frontend', '.release-build', 'assets'), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'index.html'),
|
path.join(workspace, 'app', 'game-frontend', '.release-build', 'index.html'),
|
||||||
'<!doctype html><title>static profile</title>'
|
'<!doctype html><title>static profile</title><script type="module" src="./assets/app-deadbeef.js"></script>'
|
||||||
);
|
);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets', 'app-deadbeef.js'),
|
path.join(workspace, 'app', 'game-frontend', '.release-build', 'assets', 'app-deadbeef.js'),
|
||||||
'console.log("static")'
|
'console.log("static")'
|
||||||
);
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, 'app', 'game-frontend', '.release-build', 'deployment-version.json'),
|
||||||
|
`${JSON.stringify({ buildCommitSha: SHA })}\n`
|
||||||
|
);
|
||||||
return workspace;
|
return workspace;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,12 +219,12 @@ describe('profile DEPLOY operation', () => {
|
|||||||
expect(commandGroups[0]?.[2]?.args).toContain('build:release');
|
expect(commandGroups[0]?.[2]?.args).toContain('build:release');
|
||||||
expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
||||||
expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1');
|
expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1');
|
||||||
expect(commandGroups[0]?.[3]?.args).toEqual([
|
|
||||||
'tools/build-scripts/materialize-profile-frontend.mjs',
|
|
||||||
'che:1010',
|
|
||||||
]);
|
|
||||||
expect(commandGroups[0]?.[2]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
|
expect(commandGroups[0]?.[2]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
|
||||||
expect(commandGroups[0]?.[3]?.env?.VITE_BUILD_COMMIT_SHA).toBe(SHA);
|
expect(commandGroups[0]?.[2]?.env?.VITE_ASSET_BASE_PATH).toBe('./');
|
||||||
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_APP_BASE_PATH');
|
||||||
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_API_URL');
|
||||||
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_SSE_URL');
|
||||||
|
expect(commandGroups[0]?.[2]?.env).not.toHaveProperty('VITE_GAME_PROFILE');
|
||||||
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
||||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||||
]);
|
]);
|
||||||
@@ -255,6 +259,6 @@ describe('profile DEPLOY operation', () => {
|
|||||||
expect([...running].sort()).toEqual([...backendProcessNames].sort());
|
expect([...running].sort()).toEqual([...backendProcessNames].sort());
|
||||||
expect(
|
expect(
|
||||||
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
|
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
|
||||||
).toContain('static profile');
|
).toContain('/gateway/profile-assets/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| gateway API | `app/gateway-api/src/server.ts` | 계정, session, profile, admin operation |
|
| gateway API | `app/gateway-api/src/server.ts` | 계정, session, profile, admin operation |
|
||||||
| gateway orchestrator | `app/gateway-api/src/orchestrator/orchestratorServer.ts` | DB queue, build, PM2 reconciliation |
|
| gateway orchestrator | `app/gateway-api/src/orchestrator/orchestratorServer.ts` | DB queue, build, PM2 reconciliation |
|
||||||
| release controller | `app/release-controller/src/index.ts` | Gateway 전체 릴리스와 controller CLI 전환 |
|
| release controller | `app/release-controller/src/index.ts` | Gateway 전체 릴리스와 controller CLI 전환 |
|
||||||
| game frontend | Vite preview | profile별 commit frontend artifact |
|
| game frontend | Caddy static | commit 공용 asset + profile runtime wrapper |
|
||||||
| game API | `app/game-api/src/server.ts` | profile tRPC, SSE, worker transport |
|
| game API | `app/game-api/src/server.ts` | profile tRPC, SSE, worker transport |
|
||||||
| turn daemon | `app/game-engine/src/turn/cli.ts` | schedule, command, 월간 lifecycle, DB flush |
|
| turn daemon | `app/game-engine/src/turn/cli.ts` | schedule, command, 월간 lifecycle, DB flush |
|
||||||
| battle worker | `app/game-api/src/battleSim/worker.ts` | 격리된 전투 시뮬레이션 |
|
| battle worker | `app/game-api/src/battleSim/worker.ts` | 격리된 전투 시뮬레이션 |
|
||||||
@@ -17,12 +17,13 @@
|
|||||||
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
|
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
|
||||||
prefix는 환경 변수와 배포 profile이 결정합니다.
|
prefix는 환경 변수와 배포 profile이 결정합니다.
|
||||||
|
|
||||||
현재 PM2 조립에서 game profile 하나는 frontend, API, turn daemon, auction,
|
현재 정적 운영 모드의 PM2 조립에서 game profile 하나는 API, turn daemon, auction,
|
||||||
battle-sim, tournament worker의 여섯 process를 만듭니다. 각 정의에는
|
battle-sim, tournament worker의 다섯 process를 만듭니다. 각 정의에는 `instances`나
|
||||||
`instances`나 cluster `exec_mode`가 없으므로 모두 단일 fork입니다. frontend도
|
cluster `exec_mode`가 없으므로 모두 단일 fork입니다. Frontend는 Caddy가
|
||||||
Caddy 정적 파일이 아니라 profile별 Vite preview Node process이고, API도 하나의
|
`frontend-artifacts` volume에서 직접 제공하며 profile별 Vite preview Node process를
|
||||||
Fastify process입니다. worker 역할 분리는 API event loop의 작업을 줄이지만
|
두지 않습니다. Preview 개발 모드에서만 여섯 번째 frontend process를 유지합니다.
|
||||||
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
|
API는 하나의 Fastify process입니다. worker 역할 분리는 API event loop의 작업을
|
||||||
|
줄이지만 API replica나 장애 대체 backend를 제공하지는 않습니다.
|
||||||
|
|
||||||
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
|
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
|
||||||
PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4,
|
PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4,
|
||||||
@@ -126,14 +127,17 @@ artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다.
|
|||||||
재시작 시 DB 상태와 process 상태를 reconciliation합니다.
|
재시작 시 DB 상태와 process 상태를 reconciliation합니다.
|
||||||
|
|
||||||
Profile `DEPLOY` operation은 현재 game schema와 시즌 데이터를 유지한 채 선택
|
Profile `DEPLOY` operation은 현재 game schema와 시즌 데이터를 유지한 채 선택
|
||||||
commit의 game API, engine과 profile 전용 frontend artifact를 빌드합니다. 기존
|
commit의 game API, engine과 commit 공용 frontend asset을 빌드합니다. 기존
|
||||||
프로세스를 멈춘 뒤 `prisma migrate deploy`만 실행하고 seed는 호출하지 않습니다.
|
프로세스를 멈춘 뒤 `prisma migrate deploy`만 실행하고 seed는 호출하지 않습니다.
|
||||||
새 API·frontend와 모든 worker가 PM2 `online`이고 HTTP readiness가 성공해야
|
새 API·frontend와 모든 worker가 PM2 `online`이고 HTTP readiness가 성공해야
|
||||||
build commit을 게시합니다. 실패하면 이전 worktree 프로세스를 다시 시작합니다.
|
build commit을 게시합니다. 실패하면 이전 worktree 프로세스를 다시 시작합니다.
|
||||||
Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`로
|
Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`로
|
||||||
주입합니다. 이 값은 Turbo의 `VITE_*` cache key에 포함되고 Vite가 bundle 상수로
|
주입합니다. 정적 운영 모드는 profile base/API 값을 build에서 제거하고 상대 asset
|
||||||
고정하므로, 게임의 `게임 정보` dialog가 실제 선택 build commit을 표시하며 다른
|
base를 사용하므로 같은 commit의 모든 profile이 하나의 Turbo/Vite 결과를 공유합니다.
|
||||||
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
|
Profile별 base path, API/SSE URL과 Gateway URL은 각 `current/index.html`에 JSON script로
|
||||||
|
주입되어 router와 client가 시작할 때 읽습니다. 게임의 `게임 정보` dialog가 실제 선택
|
||||||
|
build commit을 표시하며 다른 commit의 cached artifact를 현재 버전으로 오인하지
|
||||||
|
않습니다. Orchestrator 밖의
|
||||||
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
개발 build는 현재 Git checkout의 `HEAD`를 fallback으로 사용하고 Git metadata를
|
||||||
읽을 수 없을 때만 `unknown`을 표시합니다.
|
읽을 수 없을 때만 `unknown`을 표시합니다.
|
||||||
같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
|
같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
|
||||||
|
|||||||
@@ -52,11 +52,15 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
|
|||||||
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을
|
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을
|
||||||
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
|
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
|
||||||
- Server build 뒤 frontend release는 `typecheck:release`와 Vite bundle을 별도 Turbo
|
- Server build 뒤 frontend release는 `typecheck:release`와 Vite bundle을 별도 Turbo
|
||||||
task로 실행합니다. 타입검사 hash에는 frontend 자체와 직접 해석하는 common/infra/
|
task로 실행합니다. 정적 운영 모드의 profile은 profile별 base/API 값을 build env에서
|
||||||
|
제거하고 `VITE_ASSET_BASE_PATH=./`인 공용 bundle을 commit당 한 번 생성합니다.
|
||||||
|
타입검사 hash에는 frontend 자체와 직접 해석하는 common/infra/
|
||||||
logic/game-engine/game-api/gateway-api source, Prisma schema와 기본 navigation resource가
|
logic/game-engine/game-api/gateway-api source, Prisma schema와 기본 navigation resource가
|
||||||
들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type
|
들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type
|
||||||
source, base path나 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지
|
source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지
|
||||||
않습니다. 일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
|
않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의
|
||||||
|
runtime JSON config이므로 profile 사이에서 의도적으로 같은 cache key를 사용합니다.
|
||||||
|
일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
|
||||||
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
||||||
build child에 전달됩니다.
|
build child에 전달됩니다.
|
||||||
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
|
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
|
||||||
@@ -135,11 +139,15 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
|
|||||||
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
||||||
별도로 검토해 주세요.
|
별도로 검토해 주세요.
|
||||||
|
|
||||||
Profile frontend bundle은 package의 `.release-build`에 고정 생성되어 profile base
|
Profile frontend bundle은 package의 `.release-build`에 상대 asset URL로 한 번 생성되어
|
||||||
path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
|
profile과 무관한 Turbo cache에 저장됩니다. Orchestrator는 이를
|
||||||
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
|
`game-assets/releases/<commit-digest>` 불변 release로 한 번 stage하고, 각 profile에는
|
||||||
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
|
base path, API/SSE URL과 Gateway URL을 `<script type="application/json">`로 주입한 작은
|
||||||
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
|
`index.html`, `deployment-version.json`과 manifest만 게시합니다. 브라우저가 다른
|
||||||
|
profile을 열어도 `/gateway/profile-assets/<commit-digest>/assets/*` URL이 같아 JS, CSS와
|
||||||
|
source map 전송 cache를 공유합니다. Source map 생성과 공개 진단 계약은 유지합니다.
|
||||||
|
Preview 개발 모드는 기존 profile별 Vite env와 materialize 경계를 그대로 사용하며,
|
||||||
|
전환 전 profile 전용 정적 artifact도 rollback 호환 경로로 읽습니다.
|
||||||
각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다.
|
각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다.
|
||||||
열린 profile 탭은 이 파일의 고정 URL을 ETag로 조건부 재검증해 bundle SHA와 달라졌을
|
열린 profile 탭은 이 파일의 고정 URL을 ETag로 조건부 재검증해 bundle SHA와 달라졌을
|
||||||
때 한 번만 `새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를
|
때 한 번만 `새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를
|
||||||
|
|||||||
Reference in New Issue
Block a user