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
@@ -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/');
});
});