From 6e635a312f54dfdd160f03ca93dc330f4e376047 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 14:45:47 +0000 Subject: [PATCH] fix: support containerized release runtimes --- .env.example | 2 ++ .gitignore | 1 + app/game-api/src/index.ts | 19 +++++-------------- app/game-api/test/entrypoint.test.ts | 12 ++++++++++++ app/game-engine/package.json | 2 +- app/game-engine/src/index.ts | 15 ++------------- app/game-engine/test/entrypoint.test.ts | 11 +++++++++++ app/game-frontend/vite.config.ts | 14 +++++++++++++- app/gateway-api/src/index.ts | 16 +++++----------- .../src/orchestrator/gatewayOrchestrator.ts | 4 +++- app/gateway-api/test/entrypoint.test.ts | 13 +++++++++++++ app/gateway-api/test/orchestratorPlan.test.ts | 2 +- app/gateway-frontend/vite.config.ts | 14 +++++++++++++- .../src/releaseController.ts | 2 +- .../test/releaseController.test.ts | 10 +++++++++- docs/e2e-caddy-routing.md | 2 ++ docs/release-operations.md | 7 +++++++ 17 files changed, 101 insertions(+), 45 deletions(-) create mode 100644 app/game-api/test/entrypoint.test.ts create mode 100644 app/game-engine/test/entrypoint.test.ts create mode 100644 app/gateway-api/test/entrypoint.test.ts diff --git a/.env.example b/.env.example index 4dd3aff..0653a1c 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ TRPC_PATH=/trpc # Frontend public URLs # These values are public and become part of the browser bundle. VITE_GATEWAY_WEB_URL=/gateway/ +# Comma-separated public hostnames accepted by Vite preview. Use * only behind a trusted reverse proxy. +VITE_PREVIEW_ALLOWED_HOSTS=localhost,dev-sam-e2e.hided.net # Account-uploaded icons are served by the Gateway and remain a public browser URL. VITE_GATEWAY_USER_ICON_BASE_URL=/gateway/api/user-icons VITE_BOARD_COMMUNITY_URL=/xe/community diff --git a/.gitignore b/.gitignore index 9fd5350..e2d2e96 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,7 @@ docs/.vitepress/dist/ docker-compose.override.yml .turbo/ +.pnpm-store/ *_errors.txt docs/image-storage.md playwright-report/ diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index 4de2b2d..4f80559 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -1,6 +1,3 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { runGameApiServer } from './server.js'; import { runBattleSimWorker } from './battleSim/worker.js'; import { runAuctionWorker } from './auction/worker.js'; @@ -41,18 +38,12 @@ export type { TurnCommandTable } from './turns/commandTable.js'; export type { ReservedTurnView } from './turns/reservedTurns.js'; export type { JsonObject, JsonArray } from './context.js'; -const isMain = (): boolean => { - if (typeof process.env.NODE_APP_INSTANCE === 'string') { - return true; - } - if (!process.argv[1]) { - return false; - } - return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); -}; +const GAME_API_ROLES = ['server', 'battle-sim-worker', 'auction-worker', 'tournament-worker'] as const; +export const shouldRunGameApi = (role: string | undefined): boolean => + typeof role === 'string' && GAME_API_ROLES.includes(role as (typeof GAME_API_ROLES)[number]); -if (isMain()) { - const role = process.env.GAME_API_ROLE ?? 'server'; +if (shouldRunGameApi(process.env.GAME_API_ROLE)) { + const role = process.env.GAME_API_ROLE; const run = role === 'battle-sim-worker' ? runBattleSimWorker diff --git a/app/game-api/test/entrypoint.test.ts b/app/game-api/test/entrypoint.test.ts new file mode 100644 index 0000000..62a99b8 --- /dev/null +++ b/app/game-api/test/entrypoint.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldRunGameApi } from '../src/index.js'; + +describe('game-api entrypoint', () => { + it('starts only for an explicitly selected API or worker role', () => { + expect(shouldRunGameApi('server')).toBe(true); + expect(shouldRunGameApi('auction-worker')).toBe(true); + expect(shouldRunGameApi('gateway')).toBe(false); + expect(shouldRunGameApi(undefined)).toBe(false); + }); +}); diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 4bfe7ba..5f59bda 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine", "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch", - "start": "pnpm run build && node dist/index.js", + "start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js", "lint": "eslint .", "lint:fix": "eslint . --fix", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 972a635..eab7f92 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -1,6 +1,3 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { runTurnDaemonCli } from './turn/cli.js'; export * from './lifecycle/types.js'; @@ -29,17 +26,9 @@ export * from './turn/selectPoolService.js'; export * from './turn/turnDaemon.js'; export * from './turn/cli.js'; -const isMain = (): boolean => { - if (typeof process.env.NODE_APP_INSTANCE === 'string') { - return true; - } - if (!process.argv[1]) { - return false; - } - return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); -}; +export const shouldRunTurnDaemon = (role: string | undefined): boolean => role === 'turn-daemon'; -if (isMain()) { +if (shouldRunTurnDaemon(process.env.GAME_ENGINE_ROLE)) { runTurnDaemonCli().catch((error) => { console.error('[turn-daemon] failed to start', error); process.exitCode = 1; diff --git a/app/game-engine/test/entrypoint.test.ts b/app/game-engine/test/entrypoint.test.ts new file mode 100644 index 0000000..5a7dc93 --- /dev/null +++ b/app/game-engine/test/entrypoint.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldRunTurnDaemon } from '../src/index.js'; + +describe('game-engine entrypoint', () => { + it('starts only for an explicitly selected turn-daemon role', () => { + expect(shouldRunTurnDaemon('turn-daemon')).toBe(true); + expect(shouldRunTurnDaemon('api')).toBe(false); + expect(shouldRunTurnDaemon(undefined)).toBe(false); + }); +}); diff --git a/app/game-frontend/vite.config.ts b/app/game-frontend/vite.config.ts index 98633e1..d69ed51 100644 --- a/app/game-frontend/vite.config.ts +++ b/app/game-frontend/vite.config.ts @@ -11,6 +11,18 @@ const normalizeBasePath = (value: string | undefined): string => { return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`; }; +const resolvePreviewAllowedHosts = (value: string | undefined): true | string[] => { + const normalized = value?.trim(); + if (normalized === '*') { + return true; + } + const hosts = (normalized ?? 'dev-sam-e2e.hided.net') + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + return hosts; +}; + // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); @@ -28,7 +40,7 @@ export default defineConfig(({ mode }) => { }, preview: { host: '0.0.0.0', - allowedHosts: ['dev-sam-e2e.hided.net'], + allowedHosts: resolvePreviewAllowedHosts(env.VITE_PREVIEW_ALLOWED_HOSTS), }, }; }); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index 3464f56..029eeec 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -1,6 +1,3 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - import { runGatewayApiServer } from './server.js'; import { runGatewayOrchestrator } from './orchestrator/orchestratorServer.js'; import { runProfileSeedCli } from './orchestrator/profileSeedCli.js'; @@ -33,15 +30,12 @@ export * from './auth/kakaoClient.js'; export * from './auth/oauthSessionStore.js'; export * from './auth/postgresUserRepository.js'; -const isMain = (): boolean => { - if (!process.argv[1]) { - return false; - } - return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); -}; +const GATEWAY_ROLES = ['api', 'orchestrator', 'profile-seed'] as const; +export const shouldRunGateway = (role: string | undefined): boolean => + typeof role === 'string' && GATEWAY_ROLES.includes(role as (typeof GATEWAY_ROLES)[number]); -if (isMain()) { - const role = process.env.GATEWAY_ROLE ?? 'api'; +if (shouldRunGateway(process.env.GATEWAY_ROLE)) { + const role = process.env.GATEWAY_ROLE; const run = role === 'orchestrator' ? runGatewayOrchestrator diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 9be2b57..fc0d9db 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -378,13 +378,14 @@ export const buildProcessDefinitions = ( const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const frontendCwd = path.join(runtimeWorkspace, 'app', 'game-frontend'); const frontendOutDir = buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName); - const frontendScript = path.join(runtimeWorkspace, 'node_modules', 'vite', 'bin', 'vite.js'); + const frontendScript = path.join(frontendCwd, 'node_modules', 'vite', 'bin', 'vite.js'); const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine'); const apiScript = path.join(apiCwd, 'dist', 'index.js'); const daemonScript = path.join(daemonCwd, 'dist', 'index.js'); const apiEnv = { ...baseEnv, + GAME_API_ROLE: 'server', PROFILE: profile.profile, SCENARIO: profile.scenario, GAME_PROFILE_NAME: profile.profileName, @@ -398,6 +399,7 @@ export const buildProcessDefinitions = ( }; const daemonEnv = { ...baseEnv, + GAME_ENGINE_ROLE: 'turn-daemon', TURN_PROFILE: profile.profile, PROFILE: profile.profile, SCENARIO: profile.scenario, diff --git a/app/gateway-api/test/entrypoint.test.ts b/app/gateway-api/test/entrypoint.test.ts new file mode 100644 index 0000000..6bdaf3b --- /dev/null +++ b/app/gateway-api/test/entrypoint.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldRunGateway } from '../src/index.js'; + +describe('gateway entrypoint', () => { + it('starts only for an explicitly selected Gateway role', () => { + expect(shouldRunGateway('api')).toBe(true); + expect(shouldRunGateway('orchestrator')).toBe(true); + expect(shouldRunGateway('profile-seed')).toBe(true); + expect(shouldRunGateway('server')).toBe(false); + expect(shouldRunGateway(undefined)).toBe(false); + }); +}); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 2dbe774..c08d881 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -118,7 +118,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.frontend).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-frontend'), - script: path.join(buildWorkspace, 'node_modules', 'vite', 'bin', 'vite.js'), + script: path.join(buildWorkspace, 'app', 'game-frontend', 'node_modules', 'vite', 'bin', 'vite.js'), args: [ 'preview', '--host', diff --git a/app/gateway-frontend/vite.config.ts b/app/gateway-frontend/vite.config.ts index 373ba41..699047e 100644 --- a/app/gateway-frontend/vite.config.ts +++ b/app/gateway-frontend/vite.config.ts @@ -11,6 +11,18 @@ const normalizeBasePath = (value: string | undefined): string => { return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`; }; +const resolvePreviewAllowedHosts = (value: string | undefined): true | string[] => { + const normalized = value?.trim(); + if (normalized === '*') { + return true; + } + const hosts = (normalized ?? 'dev-sam-e2e.hided.net') + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + return hosts; +}; + // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); @@ -28,7 +40,7 @@ export default defineConfig(({ mode }) => { }, preview: { host: '0.0.0.0', - allowedHosts: ['dev-sam-e2e.hided.net'], + allowedHosts: resolvePreviewAllowedHosts(env.VITE_PREVIEW_ALLOWED_HOSTS), }, }; }); diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 6f6f11f..b805f6b 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -61,7 +61,7 @@ export const buildGatewayProcessDefinitions = ( const apiCwd = path.join(workspaceRoot, 'app', 'gateway-api'); const frontendCwd = path.join(workspaceRoot, 'app', 'gateway-frontend'); const apiScript = path.join(apiCwd, 'dist', 'index.js'); - const frontendScript = path.join(workspaceRoot, 'node_modules', 'vite', 'bin', 'vite.js'); + const frontendScript = path.join(frontendCwd, 'node_modules', 'vite', 'bin', 'vite.js'); const env = { ...config.baseEnv, GATEWAY_API_HOST: '0.0.0.0', diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 3b0163a..119aa3e 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -14,7 +14,7 @@ import type { import { afterEach, describe, expect, it } from 'vitest'; import { resolveReleaseControllerConfig, type ReleaseControllerConfig } from '../src/config.js'; -import { GatewayReleaseController } from '../src/releaseController.js'; +import { buildGatewayProcessDefinitions, GatewayReleaseController } from '../src/releaseController.js'; import { upgradeReleaseController } from '../src/selfUpgrade.js'; const SHA = '1111111111111111111111111111111111111111'; @@ -116,6 +116,14 @@ const createRepository = () => { const gatewayNames = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator']; +it('runs Gateway preview from the frontend workspace dependency', () => { + const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', config); + const frontend = definitions.find((definition) => definition.name === 'sammo:gateway-frontend'); + expect(frontend?.script).toBe( + '/srv/sammo/release/app/gateway-frontend/node_modules/vite/bin/vite.js' + ); +}); + describe('GatewayReleaseController', () => { it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => { const workspace = await createReleaseWorkspace(); diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index 60bd326..bf8f654 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -52,6 +52,7 @@ VITE_APP_BASE_PATH=/gateway \ VITE_GATEWAY_API_URL=/gateway/api/trpc \ VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \ VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \ +VITE_PREVIEW_ALLOWED_HOSTS=dev-sam-e2e.hided.net \ pnpm --filter @sammo-ts/gateway-frontend build ``` @@ -61,6 +62,7 @@ Game frontend는 profile별 값으로 build합니다. VITE_APP_BASE_PATH=/che \ VITE_GAME_API_URL=/che/api/trpc \ VITE_GAME_SSE_URL=/che/api/events \ +VITE_PREVIEW_ALLOWED_HOSTS=dev-sam-e2e.hided.net \ pnpm --filter @sammo-ts/game-frontend build ``` diff --git a/docs/release-operations.md b/docs/release-operations.md index 58220c3..6c16e6a 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -25,6 +25,13 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필 - Gateway PostgreSQL, profile PostgreSQL, Redis와 PM2가 준비되어 있습니다. - controller가 `GATEWAY_DATABASE_URL`, `GATEWAY_DB_SCHEMA`, workspace와 worktree 경로를 올바르게 읽습니다. +- 공개 hostname을 쉼표로 구분한 `VITE_PREVIEW_ALLOWED_HOSTS`가 Gateway와 + profile frontend build 환경에 전달됩니다. 신뢰된 reverse proxy 뒤가 아니라면 + `*`를 사용하지 않습니다. +- PM2 definition은 Gateway에 `GATEWAY_ROLE=api|orchestrator`, game API에 + `GAME_API_ROLE=server|*-worker`, turn daemon에 + `GAME_ENGINE_ROLE=turn-daemon`을 명시합니다. library import나 PM2 wrapper의 + argv만으로 실행 역할을 추론하지 않습니다. - migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이 유지됩니다.