perf: 프로필 프런트엔드 자산을 커밋 단위로 공유한다
정적 운영 빌드에서 프로필별 base와 API 설정을 런타임 JSON으로 분리하고, 공용 Vite 번들과 sourcemap을 한 번만 생성·게시한다. Preview와 기존 정적 artifact의 전환 호환 경계는 유지한다.
This commit is contained in:
@@ -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/');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user