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