perf: 프로필 프런트엔드 자산을 커밋 단위로 공유한다

정적 운영 빌드에서 프로필별 base와 API 설정을 런타임 JSON으로 분리하고, 공용 Vite 번들과 sourcemap을 한 번만 생성·게시한다. Preview와 기존 정적 artifact의 전환 호환 경계는 유지한다.
This commit is contained in:
2026-08-22 17:54:43 +00:00
parent ecc8721238
commit ade543f936
24 changed files with 628 additions and 79 deletions
@@ -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 ?? {}
);
+1
View File
@@ -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;
+2 -1
View File
@@ -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 -1
View File
@@ -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');
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+4 -3
View File
@@ -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) => {
+2 -1
View File
@@ -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');
});
});
+25
View File
@@ -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;
+4 -1
View File
@@ -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/');
}); });
}); });
+15 -11
View File
@@ -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`을 생성합니다. 이미 열린
+16 -8
View File
@@ -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를