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 { createDeploymentVersionChecker } from '../config/deploymentVersion';
import { useGameFeedback } from './useGameFeedback';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const pollIntervalMs = 60_000;
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
@@ -14,8 +15,8 @@ const resolveSessionStorage = (): Pick<Storage, 'getItem' | 'setItem'> | undefin
};
export const useDeploymentVersionNotice = (): void => {
const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
const currentCommitSha = gameFrontendRuntimeConfig.buildCommitSha;
const versionUrl = `${gameFrontendRuntimeConfig.appBasePath}deployment-version.json`;
const { info: showInfoToast } = useGameFeedback();
const checker = createDeploymentVersionChecker({
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 {
readonly VITE_APP_BASE_PATH?: string;
readonly VITE_ASSET_BASE_PATH?: string;
readonly VITE_GATEWAY_API_URL?: string;
readonly VITE_GAME_API_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 { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
@@ -384,7 +385,7 @@ const routes = [
] satisfies RouteRecordRaw[];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHistory(gameFrontendRuntimeConfig.appBasePath),
routes,
});
@@ -22,6 +22,7 @@ import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { markGameServerContact } from '../utils/gameServerActivity';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
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 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);
url.searchParams.set('token', token);
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 { gatewayTrpc } from '../utils/gatewayTrpc';
import { trpc as gameTrpc } from '../utils/trpc';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
export type SessionStatus = 'unknown' | 'public' | 'authed' | 'general';
@@ -222,7 +223,7 @@ export const useSessionStore = defineStore('session', {
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) {
this.setProfile(storedProfile);
}
@@ -8,6 +8,7 @@ import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
import { structurallyShare } from '../utils/structuralShare';
import { trpc } from '../utils/trpc';
import { useSessionStore } from './session';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.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;
};
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);
url.searchParams.set('token', token);
url.searchParams.set('scope', 'tournament');
+2 -1
View File
@@ -1,6 +1,7 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const getSessionToken = (): string | null => {
if (typeof window === 'undefined') {
@@ -13,7 +14,7 @@ const getSessionToken = (): string | null => {
export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
url: gameFrontendRuntimeConfig.gatewayApiUrl,
...trpcJsonBodyHttpClientOptions,
headers() {
const token = getSessionToken();
+2 -1
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_USER_ICON_PUBLIC_URL,
externalizeLegacyImageUrl,
} from './imageAssets.ts';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts';
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
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)) {
return normalized;
}
return `${import.meta.env.BASE_URL}${normalized.replace(/^\/+/u, '')}`;
return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`;
};
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 { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
import { markGameServerContact } from './gameServerActivity';
@@ -16,7 +17,7 @@ const getGameToken = (): string | null => {
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
url: gameFrontendRuntimeConfig.gameApiUrl,
...trpcJsonBodyHttpClientOptions,
async fetch(input, init) {
const result = await globalThis.fetch(input, init);
+2 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const gatewayUrl = import.meta.env.VITE_GATEWAY_WEB_URL || '/gateway/';
const gatewayUrl = gameFrontendRuntimeConfig.gatewayWebUrl;
onMounted(() => {
window.location.replace(gatewayUrl);
+4 -3
View File
@@ -30,6 +30,7 @@ import { useMainDashboardStore } from '../stores/mainDashboard';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
import type { CommandPatternEntry } from '../components/command/types';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
import {
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT,
@@ -45,9 +46,9 @@ const isMobile = useMediaQuery('(max-width: 939.98px)');
const npcMode = ref(0);
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
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 navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
const navigationUrl = gameFrontendRuntimeConfig.gatewayApiUrl.replace(/\/trpc\/?$/u, '/navigation');
const reloadMobilePanelOrder = () => {
mobilePanelOrder.value = loadMobileMainPanelOrder();
@@ -188,7 +189,7 @@ const requestManualRefresh = () => {
};
const moveLobby = () => {
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
window.location.replace(gameFrontendRuntimeConfig.gatewayWebUrl);
};
const moveQuick = (item: QuickNavigationItem) => {
+2 -1
View File
@@ -9,6 +9,7 @@ import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { error: showErrorToast, showDialog } = useGameFeedback();
@@ -308,7 +309,7 @@ const dieOnPrestart = async () => {
await trpc.general.dieOnPrestart.mutate({ clientRequestId });
window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY);
session.leaveGame();
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
window.location.replace(gameFrontendRuntimeConfig.gatewayWebUrl);
} catch (cause) {
const code = asRecord(asRecord(cause).data).code;
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);
});
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 () => {
const commitSha = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
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 pathValue = (value ?? '/').trim();
if (pathValue === './') {
return './';
}
if (!pathValue || pathValue === '/') {
return '/';
}
@@ -61,7 +64,7 @@ export default defineConfig(({ mode }) => {
const env = mergeViteEnv(loadEnv(mode, process.cwd(), ''), process.env);
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
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)],
define: {
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
@@ -19,9 +19,26 @@ export interface StagedFrontendArtifact {
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 FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
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 => {
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 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 (
raw.version !== 1 ||
typeof raw.frontendKey !== 'string' ||
@@ -102,6 +121,43 @@ const readManifest = async (releasePath: string): Promise<FrontendArtifactManife
const isMissing = (error: unknown): boolean =>
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 {
readonly root: string;
@@ -170,7 +226,12 @@ export class FrontendArtifactManager {
try {
await fs.rename(stagingPath, releasePath);
} 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);
if (existing.digest !== digest || existing.commitSha !== commitSha) throw error;
} else {
@@ -183,6 +244,51 @@ export class FrontendArtifactManager {
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> {
const frontendRoot = this.frontendRoot(frontendKey);
try {
@@ -57,7 +57,9 @@ import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.
import {
FrontendArtifactManager,
resolveFrontendServeMode,
SHARED_GAME_FRONTEND_KEY,
type FrontendServeMode,
type StagedFrontendArtifact,
} from './frontendArtifactManager.js';
export interface GatewayProcessConfig {
@@ -573,6 +575,9 @@ const sanitizeArtifactName = (value: string): string => value.replace(/[^0-9A-Za
const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string): string =>
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 = (
workspaceRoot: string,
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 = (
workspaceRoot: string,
needsInstall: boolean,
@@ -1556,13 +1592,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.processConfig.workspaceRoot,
['@sammo-ts/game-api']
),
...buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
),
...(this.frontendServeMode === 'static'
? buildSharedProfileFrontendCommands(
workspace.root,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
: buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)),
];
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
@@ -2148,13 +2191,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.processConfig.workspaceRoot
),
...(profile
? buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
? this.frontendServeMode === 'static'
? buildSharedProfileFrontendCommands(
workspace.root,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
: buildProfileFrontendCommands(
workspace.root,
profile,
commitSha,
this.processConfig.baseEnv,
this.processConfig.workspaceRoot
)
: []),
];
if (operationId) {
@@ -2255,10 +2305,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
return resolveGatewayPostgresConfigFromEnv(
this.processConfig.baseEnv ?? process.env,
profile.profile
).url;
return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url;
}
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> {
const definitions = buildProcessDefinitions(profile, this.processConfig);
const orderedDefinitions = [
@@ -2337,19 +2427,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const attemptedNames: string[] = [];
try {
const stagedArtifact =
this.frontendServeMode === 'static'
? 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;
this.frontendServeMode === 'static' ? await this.stageStaticProfileFrontend(profile) : null;
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
const obsoleteNames =
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 { 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 sha = 'a'.repeat(40);
@@ -70,4 +76,73 @@ describe('FrontendArtifactManager', () => {
/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']);
});
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-'));
temporaryDirectories.push(workspace);
await fs.mkdir(path.join(workspace, '.release-dist', 'che_2', 'game-frontend'), { recursive: true });
@@ -6,6 +6,7 @@ import {
buildProfileFrontendCommands,
buildProfileMigrationCommand,
buildProcessDefinitions,
buildSharedProfileFrontendCommands,
buildWorkspaceCommands,
planProfileReconcile,
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'],
})
);
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,
});
await fs.writeFile(
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'index.html'),
'<!doctype html><title>static profile</title>'
path.join(workspace, 'app', 'game-frontend', '.release-build', 'index.html'),
'<!doctype html><title>static profile</title><script type="module" src="./assets/app-deadbeef.js"></script>'
);
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")'
);
await fs.writeFile(
path.join(workspace, 'app', 'game-frontend', '.release-build', 'deployment-version.json'),
`${JSON.stringify({ buildCommitSha: SHA })}\n`
);
return workspace;
};
@@ -215,12 +219,12 @@ describe('profile DEPLOY operation', () => {
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('--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]?.[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([
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
]);
@@ -255,6 +259,6 @@ describe('profile DEPLOY operation', () => {
expect([...running].sort()).toEqual([...backendProcessNames].sort());
expect(
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 orchestrator | `app/gateway-api/src/orchestrator/orchestratorServer.ts` | DB queue, build, PM2 reconciliation |
| 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 |
| turn daemon | `app/game-engine/src/turn/cli.ts` | schedule, command, 월간 lifecycle, DB flush |
| battle worker | `app/game-api/src/battleSim/worker.ts` | 격리된 전투 시뮬레이션 |
@@ -17,12 +17,13 @@
Gateway API와 game API는 기본적으로 `0.0.0.0`에 bind합니다. 실제 port와
prefix는 환경 변수와 배포 profile이 결정합니다.
현재 PM2 조립에서 game profile 하나는 frontend, API, turn daemon, auction,
battle-sim, tournament worker의 섯 process를 만듭니다. 각 정의에는
`instances`cluster `exec_mode`가 없으므로 모두 단일 fork입니다. frontend
Caddy 정적 파일이 아니라 profile별 Vite preview Node process이고, API도 하나의
Fastify process입니다. worker 역할 분리는 API event loop의 작업을 줄이지만
frontend/API replica나 장애 대체 backend를 제공하지는 않습니다.
현재 정적 운영 모드의 PM2 조립에서 game profile 하나는 API, turn daemon, auction,
battle-sim, tournament worker의 섯 process를 만듭니다. 각 정의에는 `instances`
cluster `exec_mode`가 없으므로 모두 단일 fork입니다. Frontend는 Caddy가
`frontend-artifacts` volume에서 직접 제공하며 profile별 Vite preview Node process
두지 않습니다. Preview 개발 모드에서만 여섯 번째 frontend process를 유지합니다.
API는 하나의 Fastify process입니다. worker 역할 분리는 API event loop의 작업을
줄이지만 API replica나 장애 대체 backend를 제공하지는 않습니다.
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4,
@@ -126,14 +127,17 @@ artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다.
재시작 시 DB 상태와 process 상태를 reconciliation합니다.
Profile `DEPLOY` operation은 현재 game schema와 시즌 데이터를 유지한 채 선택
commit의 game API, engine과 profile 전용 frontend artifact를 빌드합니다. 기존
commit의 game API, engine과 commit 공용 frontend asset을 빌드합니다. 기존
프로세스를 멈춘 뒤 `prisma migrate deploy`만 실행하고 seed는 호출하지 않습니다.
새 API·frontend와 모든 worker가 PM2 `online`이고 HTTP readiness가 성공해야
build commit을 게시합니다. 실패하면 이전 worktree 프로세스를 다시 시작합니다.
Profile frontend build에는 같은 전체 commit SHA를 `VITE_BUILD_COMMIT_SHA`
주입합니다. 이 값은 Turbo의 `VITE_*` cache key에 포함되고 Vite가 bundle 상수로
고정하므로, 게임의 `게임 정보` dialog가 실제 선택 build commit을 표시하며 다른
commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orchestrator 밖의
주입합니다. 정적 운영 모드는 profile base/API 값을 build에서 제거하고 상대 asset
base를 사용하므로 같은 commit의 모든 profile이 하나의 Turbo/Vite 결과를 공유합니다.
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를
읽을 수 없을 때만 `unknown`을 표시합니다.
같은 build 단계는 profile root에 `deployment-version.json`을 생성합니다. 이미 열린
+16 -8
View File
@@ -52,11 +52,15 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
- 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가
들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type
source, base path나 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지
않습니다. 일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지
않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의
runtime JSON config이므로 profile 사이에서 의도적으로 같은 cache key를 사용합니다.
일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
`NODE_OPTIONS``RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
build child에 전달됩니다.
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
@@ -135,11 +139,15 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
별도로 검토해 주세요.
Profile frontend bundle은 package의 `.release-build`고정 생성되어 profile base
path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
Profile frontend bundle은 package의 `.release-build`상대 asset URL로 한 번 생성되어
profile과 무관한 Turbo cache에 저장됩니다. Orchestrator는 이를
`game-assets/releases/<commit-digest>` 불변 release로 한 번 stage하고, 각 profile에는
base path, API/SSE URL과 Gateway URL을 `<script type="application/json">`로 주입한 작은
`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만 담습니다.
열린 profile 탭은 이 파일의 고정 URL을 ETag로 조건부 재검증해 bundle SHA와 달라졌을
때 한 번만 `새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를