/** * 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( '服务器错误

页面渲染失败,请刷新重试。

', ); } else { res.end(); } } }); app.listen(port, () => { console.log(`[j13-web] Node 渲染服务器已启动: http://localhost:${port} (Go API: ${goApiUrl})`); });