From 1ce0f123fadf646a8c7cb6cfb605c518a308c834 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 9 Sep 2026 02:41:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Node=20=E6=B8=B2=E6=9F=93=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=EF=BC=88express=20+=20renderPage=20+=20API?= =?UTF-8?q?=20=E5=8F=8D=E4=BB=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/index.mjs:生产模式 Express 服务,静态资源 immutable 缓存 - /api /uploads /media 等前缀反代到 Go API,xfwd 透传客户端 IP - Vike renderPage 承接 SSR,渲染失败返回 500 兜底页 - /healthz 健康检查端点 --- frontend/server/index.mjs | 114 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 frontend/server/index.mjs diff --git a/frontend/server/index.mjs b/frontend/server/index.mjs new file mode 100644 index 0000000..c04e600 --- /dev/null +++ b/frontend/server/index.mjs @@ -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( + '服务器错误

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

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