From 2824e5af132e37592afcd2b0748cd82ad14f3fd9 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 04:32:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=20=EB=B0=8F=20Caddy=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80,=20=EA=B2=8C=EC=9E=84=20API=20UR?= =?UTF-8?q?L=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/index.html | 1 - app/game-frontend/src/env.d.ts | 1 + app/game-frontend/vite.config.ts | 36 ++++++-- app/gateway-frontend/index.html | 1 - app/gateway-frontend/src/env.d.ts | 4 + app/gateway-frontend/src/utils/gameTrpc.ts | 11 ++- app/gateway-frontend/src/utils/trpc.ts | 2 +- app/gateway-frontend/src/views/LobbyView.vue | 8 +- app/gateway-frontend/vite.config.ts | 36 ++++++-- docs/e2e-caddy-routing.md | 97 ++++++++++++++++++++ tools/e2e-routing-check/Caddyfile | 57 ++++++++++++ 11 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 docs/e2e-caddy-routing.md create mode 100644 tools/e2e-routing-check/Caddyfile diff --git a/app/game-frontend/index.html b/app/game-frontend/index.html index 626cb68..7e6e4af 100644 --- a/app/game-frontend/index.html +++ b/app/game-frontend/index.html @@ -2,7 +2,6 @@ - Sammo HiDCHe - Game diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts index 1d1c18c..2be9cd0 100644 --- a/app/game-frontend/src/env.d.ts +++ b/app/game-frontend/src/env.d.ts @@ -7,6 +7,7 @@ declare module '*.vue' { } interface ImportMetaEnv { + readonly VITE_APP_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/vite.config.ts b/app/game-frontend/vite.config.ts index b1a3573..1ce1363 100644 --- a/app/game-frontend/vite.config.ts +++ b/app/game-frontend/vite.config.ts @@ -1,17 +1,33 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +const normalizeBasePath = (value: string | undefined): string => { + const pathValue = (value ?? '/').trim(); + if (!pathValue || pathValue === '/') { + return '/'; + } + return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`; +}; + // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [vue(), tailwindcss()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + base: normalizeBasePath(env.VITE_APP_BASE_PATH), + plugins: [vue(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, }, - }, - server: { - port: 3001, - }, + server: { + host: '0.0.0.0', + port: 3001, + }, + preview: { + host: '0.0.0.0', + }, + }; }); diff --git a/app/gateway-frontend/index.html b/app/gateway-frontend/index.html index 606c4da..a986550 100644 --- a/app/gateway-frontend/index.html +++ b/app/gateway-frontend/index.html @@ -2,7 +2,6 @@ - 삼국지 모의전투 HiDCHe - Gateway diff --git a/app/gateway-frontend/src/env.d.ts b/app/gateway-frontend/src/env.d.ts index b24d3c1..a0b19d1 100644 --- a/app/gateway-frontend/src/env.d.ts +++ b/app/gateway-frontend/src/env.d.ts @@ -7,7 +7,11 @@ declare module '*.vue' { } interface ImportMetaEnv { + readonly VITE_APP_BASE_PATH?: string; + readonly VITE_GATEWAY_API_URL?: string; + readonly VITE_GAME_API_URL_TEMPLATE?: string; readonly VITE_GAME_WEB_URL?: string; + readonly VITE_GAME_WEB_URL_TEMPLATE?: string; } interface ImportMeta { diff --git a/app/gateway-frontend/src/utils/gameTrpc.ts b/app/gateway-frontend/src/utils/gameTrpc.ts index b7c8f16..472b0f5 100644 --- a/app/gateway-frontend/src/utils/gameTrpc.ts +++ b/app/gateway-frontend/src/utils/gameTrpc.ts @@ -3,11 +3,18 @@ import type { appRouter } from '@sammo-ts/game-api'; export type GameRouter = typeof appRouter; -export const createGameTrpc = (port: number) => { +const resolveProfileUrl = (template: string, profile: string): string => + template.replaceAll('{profile}', encodeURIComponent(profile)); + +export const createGameTrpc = (profile: string, port: number) => { + const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE; + const url = urlTemplate + ? resolveProfileUrl(urlTemplate, profile) + : `http://localhost:${port}/api/trpc`; return createTRPCProxyClient({ links: [ httpBatchLink({ - url: `http://localhost:${port}/api/trpc`, // 실제 환경에서는 도메인/경로 조정 필요 + url, }), ], }); diff --git a/app/gateway-frontend/src/utils/trpc.ts b/app/gateway-frontend/src/utils/trpc.ts index d9ccb39..dc9a03b 100644 --- a/app/gateway-frontend/src/utils/trpc.ts +++ b/app/gateway-frontend/src/utils/trpc.ts @@ -11,7 +11,7 @@ const getSessionToken = (): string | null => { export const trpc = createTRPCProxyClient({ links: [ httpBatchLink({ - url: '/api/trpc', // 실제 환경에 맞게 조정 필요 + url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc', headers() { const token = getSessionToken(); return token ? { 'x-session-token': token } : {}; diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 2124e52..c9df80f 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -44,7 +44,7 @@ onMounted(async () => { if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') { return; } - const gameTrpc = createGameTrpc(profile.apiPort); + const gameTrpc = createGameTrpc(profile.profile, profile.apiPort); const [infoResult, layoutResult, mapResult] = await Promise.allSettled([ gameTrpc.lobby.info.query(), gameTrpc.public.getMapLayout.query(), @@ -78,7 +78,11 @@ const handleLogout = async () => { }; const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => { - const baseUrl = import.meta.env.VITE_GAME_WEB_URL ?? ''; + const profile = profileName.split(':', 1)[0] ?? profileName; + const baseUrl = + import.meta.env.VITE_GAME_WEB_URL ?? + import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ?? + ''; if (!baseUrl) { return null; } diff --git a/app/gateway-frontend/vite.config.ts b/app/gateway-frontend/vite.config.ts index af423bf..8a78243 100644 --- a/app/gateway-frontend/vite.config.ts +++ b/app/gateway-frontend/vite.config.ts @@ -1,17 +1,33 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import vue from '@vitejs/plugin-vue'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +const normalizeBasePath = (value: string | undefined): string => { + const pathValue = (value ?? '/').trim(); + if (!pathValue || pathValue === '/') { + return '/'; + } + return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`; +}; + // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [vue(), tailwindcss()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + base: normalizeBasePath(env.VITE_APP_BASE_PATH), + plugins: [vue(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, }, - }, - server: { - port: 3000, - }, + server: { + host: '0.0.0.0', + port: 3000, + }, + preview: { + host: '0.0.0.0', + }, + }; }); diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md new file mode 100644 index 0000000..296f81e --- /dev/null +++ b/docs/e2e-caddy-routing.md @@ -0,0 +1,97 @@ +# dev-sam-e2e Caddy 라우팅 계약 + +`dev-sam-e2e.hided.net`은 URI prefix를 제거하지 않고 frontend/API upstream에 +전달한다. 따라서 Caddy matcher, frontend build base, 브라우저 API URL, +backend listen path가 같은 prefix를 사용해야 한다. + +## Caddy 설정 + +Caddy path matcher의 `/gateway`는 `/gateway/`나 `/gateway/api/trpc`를 +포함하지 않는다. 각 앱의 exact path는 trailing slash로 redirect하고 하위 +경로는 wildcard matcher로 받아야 한다. API matcher는 frontend matcher보다 +먼저 둔다. + +```caddyfile +redir /gateway /gateway/ 308 +@gatewayApi path /gateway/api /gateway/api/* +handle @gatewayApi { + reverse_proxy http://172.30.1.54:15001 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} +handle /gateway/* { + reverse_proxy http://172.30.1.54:15000 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} + +redir /che /che/ 308 +@cheApi path /che/api /che/api/* +handle @cheApi { + reverse_proxy http://172.30.1.54:15003 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} +handle /che/* { + reverse_proxy http://172.30.1.54:15002 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} + +redir /hwe /hwe/ 308 +@hweApi path /hwe/api /hwe/api/* +handle @hweApi { + reverse_proxy http://172.30.1.54:15015 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} +handle /hwe/* { + reverse_proxy http://172.30.1.54:15014 { + header_up X-Real-IP {remote_host} + header_down -Strict-Transport-Security + } +} +``` + +기존 `/image/*` handler는 앱 route 밖에 그대로 둔다. 위 블록은 URI를 +strip하지 않으므로 backend의 tRPC/SSE path도 public path 전체를 사용한다. + +## build/runtime 환경값 + +| 프로세스 | 포트 | 필수 경로 환경값 | +| --- | ---: | --- | +| gateway frontend | 15000 | `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}/` | +| gateway API | 15001 | `GATEWAY_API_HOST=0.0.0.0`, `GATEWAY_API_PORT=15001`, `GATEWAY_TRPC_PATH=/gateway/api/trpc` | +| che frontend | 15002 | `VITE_APP_BASE_PATH=/che/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/che/api/trpc`, `VITE_GAME_SSE_URL=/che/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=che` | +| che API | 15003 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15003`, `GAME_TRPC_PATH=/che/api/trpc`, `GAME_API_EVENTS_PATH=/che/api/events`, `GAME_UPLOAD_PATH=/che/api/uploads`, `PROFILE=che` | +| hwe frontend | 15014 | `VITE_APP_BASE_PATH=/hwe/`, `VITE_GATEWAY_API_URL=/gateway/api/trpc`, `VITE_GAME_API_URL=/hwe/api/trpc`, `VITE_GAME_SSE_URL=/hwe/api/events`, `VITE_GAME_ASSET_URL=/image`, `VITE_GAME_PROFILE=hwe` | +| hwe API | 15015 | `GAME_API_HOST=0.0.0.0`, `GAME_API_PORT=15015`, `GAME_TRPC_PATH=/hwe/api/trpc`, `GAME_API_EVENTS_PATH=/hwe/api/events`, `GAME_UPLOAD_PATH=/hwe/api/uploads`, `PROFILE=hwe` | + +`VITE_*` 값은 frontend build-time 값이다. frontend 프로세스를 시작할 때만 +설정해서는 이미 생성된 bundle이 바뀌지 않는다. Caddy가 prefix를 보존하므로 +`handle_path`로 바꾸거나 upstream에서 다시 strip하면 위 backend path와 +불일치한다. + +## 앱을 구동하지 않는 검증 + +다음 항목을 독립적으로 확인한다. + +1. Caddy config를 `caddy validate`/`caddy adapt`로 검증한다. +2. 각 upstream 대신 요청 URI와 listen port를 돌려주는 mock HTTP server를 + 붙여 public path가 올바른 port로 전달되고 prefix가 보존되는지 확인한다. +3. frontend를 임시 output directory에 build하고 `index.html`의 module/CSS + URL이 각각 `/gateway/`, `/che/`, `/hwe/`로 시작하는지 확인한다. +4. bundle에서 외부 API/SSE URL과 `/image` asset base를 확인한다. + +저장소의 `tools/e2e-routing-check/Caddyfile`은 18080에서 public Caddy +matcher를 재현하고, 19000번대 mock upstream이 선택된 service와 전달받은 +URI를 응답한다. core2026 프로세스나 DB/Redis 없이 matcher 우선순위와 +prefix 보존 여부를 반복 검증할 수 있다. + +이 검증은 proxy/build 계약을 확인하지만 실제 DB, Redis, 인증과 게임 동작을 +검증하지는 않는다. diff --git a/tools/e2e-routing-check/Caddyfile b/tools/e2e-routing-check/Caddyfile new file mode 100644 index 0000000..4f04d00 --- /dev/null +++ b/tools/e2e-routing-check/Caddyfile @@ -0,0 +1,57 @@ +{ + auto_https off + admin off +} + +:18080 { + redir /gateway /gateway/ 308 + @gatewayApi path /gateway/api /gateway/api/* + handle @gatewayApi { + reverse_proxy 127.0.0.1:19001 + } + handle /gateway/* { + reverse_proxy 127.0.0.1:19000 + } + + redir /che /che/ 308 + @cheApi path /che/api /che/api/* + handle @cheApi { + reverse_proxy 127.0.0.1:19003 + } + handle /che/* { + reverse_proxy 127.0.0.1:19002 + } + + redir /hwe /hwe/ 308 + @hweApi path /hwe/api /hwe/api/* + handle @hweApi { + reverse_proxy 127.0.0.1:19015 + } + handle /hwe/* { + reverse_proxy 127.0.0.1:19014 + } +} + +:19000 { + respond "gateway-frontend {uri}" +} + +:19001 { + respond "gateway-api {uri}" +} + +:19002 { + respond "che-frontend {uri}" +} + +:19003 { + respond "che-api {uri}" +} + +:19014 { + respond "hwe-frontend {uri}" +} + +:19015 { + respond "hwe-api {uri}" +}