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}"
+}