diff --git a/DESIGN.md b/DESIGN.md index 785df21..c896f27 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,7 +15,7 @@ ## 技术栈 - Monorepo:pnpm workspace,Node.js >= 22,TypeScript。 -- 前端:Nuxt(SPA 模式,`ssr: false`)、Vue、Vue Router、Vue I18n、Iconify。 +- 前端:Nuxt(`ssr: true`)、Vue、Vue Router、Vue I18n、Iconify。 - 后端:Node.js、Fastify、pg、PostgreSQL。 - 运维:Docker / docker compose。 - 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。 @@ -1068,7 +1068,7 @@ API 暴露边界: - Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。 - 前端浏览器 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。 +- 前端 Docker 构建使用 Nuxt server output,`frontend` 服务通过 Node 运行 `.output/server/index.mjs`;Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。 - `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。 - 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。 - 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。 diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index ac6f890..e95fb70 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -6,7 +6,7 @@ Keep this file aligned with implementation progress while the SSR migration is i ## Target State -- [ ] Nuxt runs with `ssr: true` for production. +- [x] Nuxt runs with `ssr: true` for production. - [ ] Public browsing routes render meaningful HTML on the server, including localized metadata and public business data where practical. - [ ] Authenticated, management, edit, and modal workflows remain functionally equivalent to the current SPA behavior. - [ ] No password hashes, session token hashes, verification/reset token hashes, role internals, permission internals, audit payloads, debug fields, or implementation notes are exposed through SSR payloads, API responses, generated HTML, metadata, logs, or UI. @@ -59,7 +59,7 @@ Keep this file aligned with implementation progress while the SSR migration is i ## Phase 4: Nuxt SSR Enablement -- [ ] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready. +- [x] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready. - [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts. - [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users. - [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy. @@ -79,6 +79,12 @@ Keep this file aligned with implementation progress while the SSR migration is i - `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events. - SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance. +### Phase 4 SSR Config Notes + +- `frontend/nuxt.config.ts` now uses `ssr: true`. +- `pnpm --filter @pokopia/frontend build` completed with Nuxt SSR enabled and generated Nuxt server output at `.output/server/index.mjs`. +- Production container now targets the Nuxt server entry point; Docker runtime validation remains tracked in Phase 8. + ## Phase 5: Server-Side Data And SEO - [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages. @@ -100,18 +106,26 @@ Keep this file aligned with implementation progress while the SSR migration is i ## Phase 7: Docker And Deployment -- [ ] Update frontend Docker image from static `.output/public` serving to Nuxt server output when SSR is enabled. +- [x] Update frontend Docker image from static `.output/public` serving to Nuxt server output when SSR is enabled. - [ ] Run the production container with the Nuxt server entry point rather than the current static server. -- [ ] Update `frontend_gateway` proxy behavior if SSR server health, fallback, or cache behavior changes. -- [ ] Document required environment variables, including public browser API URL, internal server API URL, site URL, and any cookie/session settings. -- [ ] Keep the upgrade maintenance page independent from Nuxt, backend API, and database. -- [ ] Preserve public frontend port `20015` unless `DESIGN.md` and compose configuration are intentionally updated together. +- [x] Update `frontend_gateway` proxy behavior if SSR server health, fallback, or cache behavior changes. +- [x] Document required environment variables, including public browser API URL, internal server API URL, site URL, and any cookie/session settings. +- [x] Keep the upgrade maintenance page independent from Nuxt, backend API, and database. +- [x] Preserve public frontend port `20015` unless `DESIGN.md` and compose configuration are intentionally updated together. + +### Phase 7 Deployment Notes + +- `frontend/package.json` now uses `nuxt build` so the production build emits Nitro server output. +- `frontend/Dockerfile` now runs `node .output/server/index.mjs` with `HOST=0.0.0.0` and `PORT=20015`; the obsolete lightweight static server file was removed. +- `frontend_gateway` continues to proxy `frontend:20015` and keep the backend health-gated maintenance fallback independent from Nuxt. +- `DESIGN.md` now documents the Nuxt server output deployment model and the existing browser API, server API, site URL, origin, and proxy environment variables. +- A local smoke check of `node frontend/.output/server/index.mjs` on port `20115` returned SSR HTML for `/` and `200` for `/robots.txt`; Docker compose runtime validation is still pending. ## Phase 8: Validation -- [ ] Run `pnpm --filter @pokopia/frontend typecheck`. -- [ ] Run `pnpm --filter @pokopia/frontend lint`. -- [ ] Run `pnpm --filter @pokopia/frontend build`. +- [x] Run `pnpm --filter @pokopia/frontend typecheck`. +- [x] Run `pnpm --filter @pokopia/frontend lint`. +- [x] Run `pnpm --filter @pokopia/frontend build`. - [ ] Do not run tests in WSL unless explicitly requested. - [ ] Ask the user to run `docker compose up --build` for runtime validation, then fix any pasted Docker output in follow-up passes. - [ ] Verify anonymous SSR HTML for public routes includes meaningful public content and metadata. @@ -123,7 +137,7 @@ Keep this file aligned with implementation progress while the SSR migration is i ## Phase 9: Cleanup - [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed. -- [ ] Remove obsolete static server usage if the production frontend container runs the Nuxt server. +- [x] Remove obsolete static server usage if the production frontend container runs the Nuxt server. - [ ] Remove obsolete `VITE_*` fallback support only after deployment configuration has fully moved to `NUXT_PUBLIC_*` or documented replacement variables. - [ ] Update `DESIGN.md` from "Nuxt SPA mode" to the final SSR deployment model. - [ ] Update `AGENTS.md` frontend stack and workflow notes to the final SSR state. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index cdee932..e994418 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -19,12 +19,12 @@ RUN pnpm --filter @pokopia/frontend build FROM node:22-alpine ENV NODE_ENV=production +ENV HOST=0.0.0.0 ENV PORT=20015 WORKDIR /app COPY --from=build /app/frontend/.output ./.output -COPY frontend/static-server.mjs ./static-server.mjs USER node EXPOSE 20015 -CMD ["node", "static-server.mjs"] +CMD ["node", ".output/server/index.mjs"] diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index c77ee0e..9194229 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -5,7 +5,7 @@ function normalizeSiteUrl(value: string | undefined): string { } export default defineNuxtConfig({ - ssr: false, + ssr: true, devtools: { enabled: false }, css: ['~/src/styles/main.css'], compatibilityDate: '2026-05-06', diff --git a/frontend/package.json b/frontend/package.json index f187b50..863e8a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "nuxt dev --host 0.0.0.0 --port 20015", - "build": "nuxt generate", + "build": "nuxt build", "lint": "nuxt typecheck", "typecheck": "nuxt typecheck", "test": "vitest run" diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 83f4a39..016e12d 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -7,8 +7,6 @@ const defaultCanonicalPath = '/'; const defaultImagePath = '/seo/pokopia-hero.jpg'; const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; let runtimeSiteUrl: string | null = null; -let currentSeo = resolveSeo(); -const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>(); type TranslationValues = Record; type Translator = (key: string, values?: TranslationValues) => string; @@ -43,6 +41,8 @@ export type ResolvedSeoConfig = { const messages = systemWordingMessages as unknown as Record; let activeTranslator: Translator | null = null; +let currentSeo: ResolvedSeoConfig | null = null; +const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>(); export function setSeoTranslator(translator: Translator): void { activeTranslator = translator; @@ -165,7 +165,7 @@ export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void { seoListeners.add(callback); - callback(currentSeo); + callback(currentSeo ?? resolveSeo()); return () => seoListeners.delete(callback); } diff --git a/frontend/static-server.mjs b/frontend/static-server.mjs deleted file mode 100644 index e69f0b5..0000000 --- a/frontend/static-server.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { createReadStream } from 'node:fs'; -import { stat } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '.output/public'); -const indexPath = path.join(root, 'index.html'); -const host = process.env.HOST ?? '0.0.0.0'; -const port = Number.parseInt(process.env.PORT ?? '20015', 10); - -const contentTypes = new Map([ - ['.css', 'text/css; charset=utf-8'], - ['.gif', 'image/gif'], - ['.html', 'text/html; charset=utf-8'], - ['.ico', 'image/x-icon'], - ['.jpg', 'image/jpeg'], - ['.js', 'text/javascript; charset=utf-8'], - ['.json', 'application/json; charset=utf-8'], - ['.png', 'image/png'], - ['.svg', 'image/svg+xml'], - ['.txt', 'text/plain; charset=utf-8'], - ['.wasm', 'application/wasm'], - ['.webmanifest', 'application/manifest+json; charset=utf-8'], - ['.webp', 'image/webp'], - ['.woff', 'font/woff'], - ['.woff2', 'font/woff2'], - ['.xml', 'application/xml; charset=utf-8'] -]); - -function isInsideRoot(filePath) { - const relativePath = path.relative(root, filePath); - return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); -} - -function resolvePath(url) { - try { - const pathname = new URL(url ?? '/', 'http://localhost').pathname; - const filePath = path.resolve(root, `.${decodeURIComponent(pathname)}`); - return isInsideRoot(filePath) ? filePath : indexPath; - } catch { - return indexPath; - } -} - -async function findStaticFile(url) { - const filePath = resolvePath(url); - try { - const fileStat = await stat(filePath); - if (fileStat.isFile()) { - return { filePath, fileStat }; - } - } catch { - return { filePath: indexPath, fileStat: await stat(indexPath) }; - } - - return { filePath: indexPath, fileStat: await stat(indexPath) }; -} - -createServer(async (request, response) => { - if (request.method !== 'GET' && request.method !== 'HEAD') { - response.writeHead(405); - response.end(); - return; - } - - const { filePath, fileStat } = await findStaticFile(request.url); - const contentType = contentTypes.get(path.extname(filePath)) ?? 'application/octet-stream'; - - response.writeHead(200, { - 'Cache-Control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable', - 'Content-Length': fileStat.size, - 'Content-Type': contentType - }); - - if (request.method === 'HEAD') { - response.end(); - return; - } - - createReadStream(filePath).pipe(response); -}).listen(port, host);