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
@@ -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>();