问题模式:请求时向自己借数据
不少 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) 的路径,等于让服务端进程在响应请求的同时向自己发起新的请求。常见故障场景:
- HTTPS 强制 + HSTS 回环:nginx 对 HTTP 一律 301 到 HTTPS,回环请求跟随后可能撞上自签证书或
localhost不匹配 SNI 的握手失败。 - 网络策略禁止 self-connect:容器网络、防火墙或 systemd 沙箱(
IPAddressDeny、ProtectSystem)可能直接拒绝进程连接自己的监听端口。 - 无超时的悬挂:
fetch没有signal,链路任何一环卡住,整个页面渲染就卡住,最终由上游超时给出白屏或 502。 - 错误伪装成空态:
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 文案里体现出来——这是对读者诚实,也是对未来的自己友善。