feat(frontend): enable Nuxt SSR and migrate to Nitro server
Set `ssr: true` in Nuxt config and switch build command to `nuxt build`. Update Dockerfile to run `.output/server/index.mjs` and remove static server. Defer SEO initialization to prevent premature evaluation during SSR.
This commit is contained in:
@@ -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 分钟内恢复。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
type Translator = (key: string, values?: TranslationValues) => string;
|
||||
@@ -43,6 +41,8 @@ export type ResolvedSeoConfig = {
|
||||
|
||||
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user