From afed40912745cb2656b983982da824a644bd545d Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 09:31:11 +0800 Subject: [PATCH] feat(frontend): support separate browser and server API base URLs Add NUXT_SERVER_API_BASE_URL for internal server-side API requests Update API and i18n services to select base URL by execution context --- .env.example | 6 ++++ DESIGN.md | 3 +- SSR_MIGRATION_TASKLIST.md | 28 ++++++++++------ docker-compose.yml | 4 +++ frontend/Dockerfile | 2 ++ frontend/nuxt.config.ts | 6 ++++ frontend/plugins/00-runtime-config.ts | 13 +++++--- frontend/src/i18n.ts | 31 +++++++++++++++--- frontend/src/services/api.ts | 47 +++++++++++++++++++++------ 9 files changed, 111 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 1e0d6ae..10a68a4 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ TRUST_PROXY=false FRONTEND_ORIGIN=http://localhost:20015 APP_ORIGIN=http://localhost:20015 BACKEND_PUBLIC_ORIGIN=http://localhost:20016 +NUXT_PUBLIC_API_BASE_URL=http://localhost:20016 +NUXT_SERVER_API_BASE_URL=http://localhost:3001 +NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com VITE_API_BASE_URL=http://localhost:20016 VITE_SITE_URL=https://pokopiawiki.tootaio.com RESEND_API_KEY= @@ -21,4 +24,7 @@ AI_MODERATION_API_KEY= # FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015 # APP_ORIGIN=https://pokopiawiki.tootaio.com # BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com +# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com +# NUXT_SERVER_API_BASE_URL=http://backend:3001 +# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com # VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com diff --git a/DESIGN.md b/DESIGN.md index 66ae2f7..7a01959 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1061,7 +1061,8 @@ API 暴露边界: ## 部署与升级维护 - Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。 -- 前端 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供;Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback。 +- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供;Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback。 +- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`。 - 前端 Docker 构建使用 Nuxt static generate 输出 `.output/public`,`frontend` 服务继续通过轻量 Node 静态服务器提供 SPA fallback。 - `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。 - 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。 diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 36c8775..53c4a45 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -14,19 +14,27 @@ Keep this file aligned with implementation progress while the SSR migration is i ## Phase 1: Baseline Audit -- [ ] Read `DESIGN.md`, `DesignGuidelines.html` when UI behavior is touched, `AGENTS.md`, and this task list before making SSR migration changes. -- [ ] Inventory all browser-only access in `frontend/src`, `frontend/app.vue`, `frontend/plugins`, `frontend/middleware`, and `frontend/pages`: `window`, `document`, `localStorage`, `sessionStorage`, DOM measurement, event listeners, timers, clipboard, `confirm`, `matchMedia`, and direct head mutation. -- [ ] Classify each browser-only usage as client-only component behavior, SSR-safe fallback behavior, or logic that must be moved into Nuxt composables/plugins. -- [ ] Inventory route-level data loading across public list/detail pages, authenticated pages, management pages, and route-backed modal pages. -- [ ] Identify public routes that should SSR first: Home, Pokemon/Event Pokemon lists, Habitat/Event Habitat lists, Items/Event Items lists, Ancient Artifacts, Recipes, Daily CheckList, Dish, Life list/detail, Project Updates, legal pages, and public Profile. -- [ ] Identify routes that should remain client-only or mostly client-rendered initially: Login, Register, Forgot/Reset Password, Verify Email, Admin, `/profile`, notification UI, upload/edit forms, and route-backed edit/create modals. +- [x] Read `DESIGN.md`, `DesignGuidelines.html` when UI behavior is touched, `AGENTS.md`, and this task list before making SSR migration changes. +- [x] Inventory all browser-only access in `frontend/src`, `frontend/app.vue`, `frontend/plugins`, `frontend/middleware`, and `frontend/pages`: `window`, `document`, `localStorage`, `sessionStorage`, DOM measurement, event listeners, timers, clipboard, `confirm`, `matchMedia`, and direct head mutation. +- [x] Classify each browser-only usage as client-only component behavior, SSR-safe fallback behavior, or logic that must be moved into Nuxt composables/plugins. +- [x] Inventory route-level data loading across public list/detail pages, authenticated pages, management pages, and route-backed modal pages. +- [x] Identify public routes that should SSR first: Home, Pokemon/Event Pokemon lists, Habitat/Event Habitat lists, Items/Event Items lists, Ancient Artifacts, Recipes, Daily CheckList, Dish, Life list/detail, Project Updates, legal pages, and public Profile. +- [x] Identify routes that should remain client-only or mostly client-rendered initially: Login, Register, Forgot/Reset Password, Verify Email, Admin, `/profile`, notification UI, upload/edit forms, and route-backed edit/create modals. + +### Phase 1 Audit Notes + +- Browser-only access is concentrated in client interactions: modal focus/body locking, dropdown positioning, sidebar/mobile drawer behavior, search debounce, infinite-scroll sentinels, upload/download helpers, clipboard copy, local checklist state, route-backed form defaults, WebSocket notifications, moderation update events, and destructive-action `window.confirm` calls. These should stay in mounted/client-only lifecycle paths during SSR enablement. +- SSR-safe fallback candidates already guard storage or DOM access in `frontend/src/i18n.ts`, `frontend/src/services/api.ts`, and several views with `typeof window`, `typeof document`, `typeof localStorage`, `typeof sessionStorage`, or `typeof IntersectionObserver`. +- Logic that must move to SSR-aware Nuxt APIs in later phases: direct SEO mutation in `frontend/src/seo.ts` / `frontend/plugins/02-seo.client.ts`, global Vue I18n singleton request state, auth middleware's storage-only token model, and Nuxt config analytics script injection. +- Current route data loading is client-mounted in views rather than route-level `useAsyncData` / `useFetch`. Public list/detail candidates load through `api.*Page`, `api.*Detail`, `api.dish`, `api.dailyChecklistPage`, `api.lifePosts`, `api.lifePost`, `api.projectUpdates`, and public profile/activity endpoints. Auth, admin, edit/create modal, notification, upload, comment/reaction, and profile account flows depend on client auth state or browser APIs and should not be first-wave SSR data routes. +- First SSR data groups should be low-risk public reads: Home/project update preview, legal/static pages, Pokemon/Event Pokemon lists and details, Habitat/Event Habitat lists and details, Items/Event Items/Ancient Artifacts lists and details, Recipes list/details, Daily CheckList, Dish, Life public feed/detail, Project Updates, and public Profile. ## Phase 2: Runtime Config And API Layer -- [ ] Replace client-only API base URL setup with an SSR-safe runtime config helper that works in server and client contexts. -- [ ] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches. -- [ ] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented. -- [ ] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs. +- [x] Replace client-only API base URL setup with an SSR-safe runtime config helper that works in server and client contexts. +- [x] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches. +- [x] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented. +- [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs. - [ ] Keep frontend API response types consistent with `frontend/src/services/api.ts`. - [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML. diff --git a/docker-compose.yml b/docker-compose.yml index 350790b..f8bc8a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,9 +41,13 @@ services: dockerfile: frontend/Dockerfile args: NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016} + NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001} NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com} environment: PORT: 20015 + NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016} + NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001} + NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com} expose: - "20015" depends_on: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 49fbe91..cdee932 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,8 +9,10 @@ COPY frontend ./frontend COPY system-wordings.ts ./system-wordings.ts ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001 +ARG NUXT_SERVER_API_BASE_URL=http://localhost:3001 ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL +ENV NUXT_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL RUN pnpm --filter @pokopia/frontend build diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 88fd523..3cfc1a2 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -10,6 +10,12 @@ export default defineNuxtConfig({ css: ['~/src/styles/main.css'], compatibilityDate: '2026-05-06', runtimeConfig: { + serverApiBaseUrl: + process.env.NUXT_SERVER_API_BASE_URL ?? + process.env.NUXT_API_BASE_URL ?? + process.env.NUXT_PUBLIC_API_BASE_URL ?? + process.env.VITE_API_BASE_URL ?? + 'http://localhost:3001', public: { apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001', siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL) diff --git a/frontend/plugins/00-runtime-config.ts b/frontend/plugins/00-runtime-config.ts index 21f2153..e05d480 100644 --- a/frontend/plugins/00-runtime-config.ts +++ b/frontend/plugins/00-runtime-config.ts @@ -1,10 +1,15 @@ -import { setSystemWordingsApiBaseUrl } from '../src/i18n'; +import { setSystemWordingsApiBaseUrls } from '../src/i18n'; import { setConfiguredSiteUrl } from '../src/seo'; -import { setApiBaseUrl } from '../src/services/api'; +import { setApiBaseUrls } from '../src/services/api'; export default defineNuxtPlugin(() => { const config = useRuntimeConfig(); - setApiBaseUrl(config.public.apiBaseUrl); - setSystemWordingsApiBaseUrl(config.public.apiBaseUrl); + const apiBaseUrls = { + browser: config.public.apiBaseUrl, + server: config.serverApiBaseUrl + }; + + setApiBaseUrls(apiBaseUrls); + setSystemWordingsApiBaseUrls(apiBaseUrls); setConfiguredSiteUrl(config.public.siteUrl); }); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ea758b9..7ef9df4 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n'; import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings'; export { defaultLocale } from '../../system-wordings'; -let apiBaseUrl = 'http://localhost:3001'; +let browserApiBaseUrl = 'http://localhost:3001'; +let serverApiBaseUrl = 'http://localhost:3001'; const localeStorageKey = 'pokopia_locale'; const localeChangeEvent = 'pokopia-locale-change'; @@ -26,9 +27,31 @@ export const i18n = createI18n({ }); export function setSystemWordingsApiBaseUrl(value: unknown): void { - if (typeof value === 'string' && value.trim() !== '') { - apiBaseUrl = value.trim(); + setSystemWordingsApiBaseUrls({ browser: value, server: value }); +} + +export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void { + const browserBaseUrl = normalizeApiBaseUrl(value.browser); + const serverBaseUrl = normalizeApiBaseUrl(value.server); + + if (browserBaseUrl) { + browserApiBaseUrl = browserBaseUrl; } + if (serverBaseUrl) { + serverApiBaseUrl = serverBaseUrl; + } +} + +function normalizeApiBaseUrl(value: unknown): string | null { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim().replace(/\/+$/, ''); + } + + return null; +} + +function activeApiBaseUrl(): string { + return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl; } function readStoredLocale(): string { @@ -87,7 +110,7 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa const loadPromise = (async () => { try { - const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`); + const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`); if (!response.ok) { throw new Error(`System wordings failed (${response.status})`); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1146b44..2c904d2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,6 +1,7 @@ import { getCurrentLocale } from '../i18n'; -let apiBaseUrl = 'http://localhost:3001'; +let browserApiBaseUrl = 'http://localhost:3001'; +let serverApiBaseUrl = 'http://localhost:3001'; const authTokenKey = 'pokopia_auth_token'; const authChangeEvent = 'pokopia-auth-change'; @@ -16,9 +17,35 @@ export interface Language { } export function setApiBaseUrl(value: unknown): void { - if (typeof value === 'string' && value.trim() !== '') { - apiBaseUrl = value.trim(); + setApiBaseUrls({ browser: value, server: value }); +} + +export function setApiBaseUrls(value: { browser?: unknown; server?: unknown }): void { + const browserBaseUrl = normalizeApiBaseUrl(value.browser); + const serverBaseUrl = normalizeApiBaseUrl(value.server); + + if (browserBaseUrl) { + browserApiBaseUrl = browserBaseUrl; } + if (serverBaseUrl) { + serverApiBaseUrl = serverBaseUrl; + } +} + +function normalizeApiBaseUrl(value: unknown): string | null { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim().replace(/\/+$/, ''); + } + + return null; +} + +function activeApiBaseUrl(): string { + return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl; +} + +function apiUrl(path: string): string { + return `${activeApiBaseUrl()}${path}`; } export type SystemWordingSurface = 'frontend' | 'backend' | 'email'; @@ -1086,7 +1113,7 @@ function requestHeaders(): HeadersInit { } export function notificationWebSocketUrl(ticket: string): string { - const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin); + const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin); base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:'; base.pathname = '/api/notifications/ws'; base.search = ''; @@ -1108,7 +1135,7 @@ async function getErrorMessage(response: Response): Promise { } async function getJson(path: string, signal?: AbortSignal): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { headers: requestHeaders(), signal }); @@ -1121,7 +1148,7 @@ async function getJson(path: string, signal?: AbortSignal): Promise { } async function sendJson(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { method, headers: { 'Content-Type': 'application/json', @@ -1138,7 +1165,7 @@ async function sendJson(path: string, method: 'PATCH' | 'POST' | 'PUT', body: } async function sendFormData(path: string, body: FormData): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { method: 'POST', headers: requestHeaders(), body @@ -1152,7 +1179,7 @@ async function sendFormData(path: string, body: FormData): Promise { } async function postEmpty(path: string): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { method: 'POST', headers: requestHeaders() }); @@ -1163,7 +1190,7 @@ async function postEmpty(path: string): Promise { } async function deleteJson(path: string): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { method: 'DELETE', headers: requestHeaders() }); @@ -1174,7 +1201,7 @@ async function deleteJson(path: string): Promise { } async function deleteAndGetJson(path: string): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { + const response = await fetch(apiUrl(path), { method: 'DELETE', headers: requestHeaders() });