diff --git a/app/game-frontend/src/composables/useDeploymentVersionNotice.ts b/app/game-frontend/src/composables/useDeploymentVersionNotice.ts index 9f1f9299..1ebacb6d 100644 --- a/app/game-frontend/src/composables/useDeploymentVersionNotice.ts +++ b/app/game-frontend/src/composables/useDeploymentVersionNotice.ts @@ -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 | 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, diff --git a/app/game-frontend/src/config/runtimeConfig.ts b/app/game-frontend/src/config/runtimeConfig.ts new file mode 100644 index 00000000..0da53ad8 --- /dev/null +++ b/app/game-frontend/src/config/runtimeConfig.ts @@ -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 | undefined): Record => { + 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) + : {}; + } catch { + return {}; + } +}; + +export const resolveGameFrontendRuntimeConfig = ( + documentValue: Pick | 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 ?? {} +); diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts index d9367ebf..499a4140 100644 --- a/app/game-frontend/src/env.d.ts +++ b/app/game-frontend/src/env.d.ts @@ -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; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index d90bc034..769bb209 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -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, }); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 6c3c51ec..ad44ed3f 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -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(); diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index f792ac64..eb3ff072 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -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); } diff --git a/app/game-frontend/src/stores/tournamentPages.ts b/app/game-frontend/src/stores/tournamentPages.ts index 4ec3afae..18341c16 100644 --- a/app/game-frontend/src/stores/tournamentPages.ts +++ b/app/game-frontend/src/stores/tournamentPages.ts @@ -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>; type BettingSummary = Awaited>; @@ -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'); diff --git a/app/game-frontend/src/utils/gatewayTrpc.ts b/app/game-frontend/src/utils/gatewayTrpc.ts index f93d4eb9..4416383d 100644 --- a/app/game-frontend/src/utils/gatewayTrpc.ts +++ b/app/game-frontend/src/utils/gatewayTrpc.ts @@ -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({ links: [ httpBatchLink({ - url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', + url: gameFrontendRuntimeConfig.gatewayApiUrl, ...trpcJsonBodyHttpClientOptions, headers() { const token = getSessionToken(); diff --git a/app/game-frontend/src/utils/generalIcon.ts b/app/game-frontend/src/utils/generalIcon.ts index be203186..17be127f 100644 --- a/app/game-frontend/src/utils/generalIcon.ts +++ b/app/game-frontend/src/utils/generalIcon.ts @@ -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 => { diff --git a/app/game-frontend/src/utils/trpc.ts b/app/game-frontend/src/utils/trpc.ts index bd291ee4..1c4a9e45 100644 --- a/app/game-frontend/src/utils/trpc.ts +++ b/app/game-frontend/src/utils/trpc.ts @@ -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({ 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); diff --git a/app/game-frontend/src/views/LoginView.vue b/app/game-frontend/src/views/LoginView.vue index 8bd53ff9..a140a6fa 100644 --- a/app/game-frontend/src/views/LoginView.vue +++ b/app/game-frontend/src/views/LoginView.vue @@ -1,7 +1,8 @@ \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; + }): Promise { + 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 { const frontendRoot = this.frontendRoot(frontendKey); try { diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index b603f431..3e78b2ad 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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, @@ -609,6 +614,37 @@ export const buildProfileFrontendCommands = ( ]; }; +export const buildSharedProfileFrontendCommands = ( + workspaceRoot: string, + buildCommitSha: string, + env?: Record, + 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 { @@ -2324,6 +2371,49 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } } + private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise { + 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): Promise { 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(); diff --git a/app/gateway-api/test/frontendArtifactManager.test.ts b/app/gateway-api/test/frontendArtifactManager.test.ts index 0eaab349..2c221e4b 100644 --- a/app/gateway-api/test/frontendArtifactManager.test.ts +++ b/app/gateway-api/test/frontendArtifactManager.test.ts @@ -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'), + '' + ); + 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: '', + sharedReleaseId: releaseId, + sharedAssetPublicBase: '/gateway/profile-assets', + runtimeConfig: { + version: 1, + profile: 'che', + profileName: 'che:default', + appBasePath: '/che/', + gameApiUrl: '/che/api/trpc?', + gameSseUrl: '/che/api/events', + gatewayApiUrl: '/gateway/api/trpc', + gatewayWebUrl: '/gateway/', + buildCommitSha: sha, + assetReleaseId: releaseId, + }, + }); + + expect(rendered).not.toContain('trpc?'); + expect(rendered).toContain('\\u003c/script\\u003e'); + }); }); diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 6f7be476..5740e5a5 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -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'), + '' + ); + 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 }); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 376fefc7..5ee3535e 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -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.' + ); + }); +}); diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 93d1ec01..b50a5059 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -37,17 +37,21 @@ const createReleaseWorkspace = async (): Promise => { 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'), - 'static profile' + path.join(workspace, 'app', 'game-frontend', '.release-build', 'index.html'), + 'static profile' ); 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/'); }); }); diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 4e8b2bc7..9cc22d7e 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -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`을 생성합니다. 이미 열린 diff --git a/docs/release-operations.md b/docs/release-operations.md index 6a36062d..ac76163f 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -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 build`의 typecheck 계약은 그대로입니다. + source와 공개 build-time 값이 다른 frontend artifact를 cache hit로 잘못 복원하지 + 않습니다. Profile base path와 API/SSE URL은 공용 bundle 입력이 아니라 게시 시점의 + runtime JSON config이므로 profile 사이에서 의도적으로 같은 cache key를 사용합니다. + 일반 개발용 `pnpm --filter 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//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/` 불변 release로 한 번 stage하고, 각 profile에는 +base path, API/SSE URL과 Gateway URL을 `