KAIROS.WORKSPACE
技术宇宙 / ONLINE
返回文章列表
自部署运维intermediate9 分钟

SSR 页面为什么不该向自己发 HTTP 请求:回环依赖与文件直读

分析 SSR 页面在运行时 fetch 自身站点静态文件的隐性风险,给出按候选根目录直读 public 产物、HTTP 兜底并区分空数据与数据不可用的实现模式。

#Astro#SSR#Node.js#部署
排版
字号
行宽
目录 · 5 节

问题模式:请求时向自己借数据

不少 SSR 站点有这类代码:页面在服务端渲染时需要一份构建期生成的 JSON(搜索索引、图谱数据、链接体检报告),于是直接在 setup 里写:

const response = await fetch(new URL('/graph-data.json', Astro.url).toString());
const data = await response.json();

它在本地开发几乎总能工作:dev server 正在运行,Astro.url 指向的就是它自己。但在真实部署链路里,这个请求要完整走一遍 nginx → Node 进程 → (可能再回到 nginx) 的路径,等于让服务端进程在响应请求的同时向自己发起新的请求。常见故障场景:

  1. HTTPS 强制 + HSTS 回环:nginx 对 HTTP 一律 301 到 HTTPS,回环请求跟随后可能撞上自签证书或 localhost 不匹配 SNI 的握手失败。
  2. 网络策略禁止 self-connect:容器网络、防火墙或 systemd 沙箱(IPAddressDenyProtectSystem)可能直接拒绝进程连接自己的监听端口。
  3. 无超时的悬挂fetch 没有 signal,链路任何一环卡住,整个页面渲染就卡住,最终由上游超时给出白屏或 502。
  4. 错误伪装成空态catch 后把数据置空,用户看到“暂无数据”,运维看日志毫无线索——这是最难排查的一类问题:失败被渲染成了合法的空

修复模式:磁盘直读为主,HTTP 兜底

构建期生成物(public/graph-data.json 等)在部署后就是磁盘上的静态文件,dist/client/ 里还有一份构建拷贝。服务端渲染要读它们,最直接的路径就是 fs.readFile

// src/lib/public-data.ts
import { readFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';

export type PublicJsonResult<T> = { data: T | null; ok: boolean };

export const readPublicJson = async <T>(
  fileName: string,
  selfUrl?: URL | string,
): Promise<PublicJsonResult<T>> => {
  const roots = [
    join(process.cwd(), 'public'),
    join(process.cwd(), 'dist', 'client'),
  ];
  for (const root of roots) {
    try {
      const raw = await readFile(join(root, fileName), 'utf8');
      return { data: JSON.parse(raw) as T, ok: true };
    } catch {
      // 换下一个候选根目录
    }
  }
  if (selfUrl) {
    try {
      const response = await fetch(new URL(`/${fileName}`, selfUrl), {
        signal: AbortSignal.timeout(2500),
        cache: 'no-store',
      });
      if (response.ok) return { data: (await response.json()) as T, ok: true };
    } catch {
      // 自请求只是兜底,失败不再升级
    }
  }
  return { data: null, ok: false };
};

三个要点:

  • 候选根目录按部署布局排列。PM2 / standalone 通常以仓库根为 cwd,此时 public/ 可直接命中;如果未来改为只发布 dist/ 的精简布局,dist/client 候选仍然成立。
  • HTTP 兜底必须带超时。用 AbortSignal.timeout 给回环路径一个明确的生命周期,防止兜底本身成为悬挂源。
  • 返回值携带 ok 标志。让调用方能区分“读到了,数据本来就是空的”和“没读到”。

页面侧:把两种“空”分开渲染

const result = await readPublicJson<GraphData>('graph-data.json', Astro.url);
const unavailable = !result.ok;
const graphData = result.data ?? { nodes: [], edges: [] };
{graphData.nodes.length === 0 && (
  <p>{unavailable ? '数据暂时不可用,请稍后刷新重试。' : '暂无节点数据。'}</p>
)}

对读者来说这是两句话;对排障来说这是一条分界线:“不可用”出现在日志和页面提示里,“空”才是内容问题。书签页同理——体检数据读不到时按“未检查”降级,并明说“收藏与打开不受影响”,避免一个模块的数据故障伪装成整页功能损坏。

什么时候仍然可以用自请求

不是所有回环 fetch 都错。下面这些条件同时满足时风险可控:需要的是只有 HTTP 层才有的语义(缓存头、真实状态码探测)、请求目标是独立服务而非本进程、且调用方设置了超时与失败分支。只是读一份本地产物就用 HTTP,等于给最简单的路径人为加上网络这一层失败面。

小结

  • SSR setup 里 fetch(Astro.url 同域路径) 是隐性回环依赖,在强制 HTTPS、网络策略、沙箱化 systemd 环境中可能稳定失败。
  • 构建期生成物请直接从 public/dist/client/ 候选路径读磁盘;HTTP 兜底必须限时。
  • 永远区分“数据为空”与“数据不可用”,并在 UI 文案里体现出来——这是对读者诚实,也是对未来的自己友善。

Conversation

评论与互动

正在加载评论…

提交后需审核,不会立即公开。

Keep exploring

继续探索