feat: Node 渲染服务器(express + renderPage + API 反代)

- server/index.mjs:生产模式 Express 服务,静态资源 immutable 缓存
- /api /uploads /media 等前缀反代到 Go API,xfwd 透传客户端 IP
- Vike renderPage 承接 SSR,渲染失败返回 500 兜底页
- /healthz 健康检查端点
This commit is contained in:
2026-09-09 02:41:39 +08:00
parent c99238c1e7
commit 1ce0f123fa

114
frontend/server/index.mjs Normal file
View File

@@ -0,0 +1,114 @@
/**
* Node 渲染服务器(生产模式)。
*
* 双进程架构对外单点:
* - Node :3000 —— 前端静态资源 + Vike SSR(/、/board/:id、/post/:id 真渲染,其余 SPA 壳)
* - Go :3001 —— 纯 API(/api、/uploads、/media、/oauth、robots/sitemap/favicon 反代至此)
*
* dev 模式不经过本文件:`npm run dev`(vike dev :5173)+ Go dev(API :3000)。
*/
import { Readable } from 'node:stream';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import express from 'express';
import compression from 'compression';
import { createProxyMiddleware } from 'http-proxy-middleware';
// 本文件仅用于生产(dev 走 `vike dev`);须在加载 vike/server 前定型环境
process.env.NODE_ENV ??= 'production';
const { renderPage } = await import('vike/server');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const distClient = path.join(root, 'dist', 'client');
const port = Number(process.env.PORT || 3000);
const goApiUrl = process.env.GO_API_URL || 'http://127.0.0.1:3001';
/** 反代到 Go 的前缀(对齐旧 Go ServePublicSPA 的资源分发面) */
const GO_PROXY_PREFIXES = [
'/api',
'/uploads',
'/media',
'/oauth',
'/.well-known',
'/robots.txt',
'/sitemap.xml',
'/favicon.ico',
];
if (!fs.existsSync(distClient)) {
console.error(`[j13-web] 未找到构建产物 ${distClient},请先执行 npm run build`);
process.exit(1);
}
const app = express();
app.disable('x-powered-by');
app.use(compression());
// Node 自身探活(不依赖 Go,供容器健康检查)
app.get('/healthz', (_req, res) => {
res.status(200).send('ok');
});
// Go 反代:透传 method/headers/body;xfwd 附加 X-Forwarded-*,供 Go 推导站点 base URL。
// express app.use(prefix) 会剥掉挂载前缀(req.url 不含 prefix),故 target 须补回对应前缀
for (const prefix of GO_PROXY_PREFIXES) {
app.use(
prefix,
createProxyMiddleware({
target: `${goApiUrl}${prefix}`,
xfwd: true,
}),
);
}
// 前端静态资源:dist/client(含 /assets 与 /stickers),assets 带 contenthash → immutable
app.use(
express.static(distClient, {
setHeaders(res, filePath) {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
}),
);
// 其余路径交给 Vike:feed 页 SSR 文档,spa 页输出壳(客户端接管)
app.use(async (req, res, next) => {
try {
const pageContext = await renderPage({
urlOriginal: req.originalUrl,
headersOriginal: req.headers,
});
const { httpResponse } = pageContext;
if (!httpResponse) return next();
const { statusCode, headers, body } = httpResponse;
res.statusCode = statusCode;
for (const [name, value] of headers) res.setHeader(name, value);
if (typeof body === 'string') {
res.send(body);
} else {
Readable.fromWeb(body).pipe(res);
}
} catch (err) {
// feedData/onRenderHtml 已内置降级(Go 不可达 → SPA 壳),此处仅兜 renderPage 本身的异常
console.error('[j13-web] renderPage 失败:', err);
if (!res.headersSent) {
res
.status(500)
.type('html')
.send(
'<!DOCTYPE html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>服务器错误</title><body><p>页面渲染失败,请刷新重试。</p></body></html>',
);
} else {
res.end();
}
}
});
app.listen(port, () => {
console.log(`[j13-web] Node 渲染服务器已启动: http://localhost:${port} (Go API: ${goApiUrl})`);
});