Files
jiang13-forum/frontend/server/index.mjs
freefire 2392768868 feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined:
  抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫
- 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到
  各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记
- 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false,
  useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出,
  head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块)
- 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、
  探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image
- Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、
  自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生
  历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView +
  scroll-margin-top 适配 sticky 顶栏
2026-09-09 20:53:28 +08:00

122 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
// HTML 用 no-cache(每次协商),避免 no-store/max-age=0 导致浏览器丢弃历史滚动状态(F5 不恢复位置)
app.use(
express.static(distClient, {
cacheControl: false, // 关闭 send 自动写入的 Cache-Control,统一由 setHeaders 决定
setHeaders(res, filePath) {
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
res.setHeader('Cache-Control', 'no-cache');
}
},
}),
);
// 其余路径交给 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);
// Vike 默认对 HTML 发 no-store:会使浏览器丢弃该历史条目的滚动恢复状态(F5 回到顶部)。
// 改为 no-cache:文档仍每次重新验证/渲染,但保留浏览器原生 scroll restoration。
res.setHeader('Cache-Control', 'no-cache');
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})`);
});