- server/index.mjs:生产模式 Express 服务,静态资源 immutable 缓存 - /api /uploads /media 等前缀反代到 Go API,xfwd 透传客户端 IP - Vike renderPage 承接 SSR,渲染失败返回 500 兜底页 - /healthz 健康检查端点
115 lines
3.7 KiB
JavaScript
115 lines
3.7 KiB
JavaScript
/**
|
||
* 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})`);
|
||
});
|