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:
2026-05-06 10:28:12 +08:00
parent cf1eb6965e
commit 35ee164794
7 changed files with 34 additions and 102 deletions

View File

@@ -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"]

View File

@@ -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',

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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);