index.txt 导航问题排查记录最近在给公司官网做静态部署时,踩到了一个非常诡异的坑:
首页正常,但偶尔点导航
/news,浏览器 URL 会变成/news/index.txt,页面直接展示一坨 RSC 文本。
这个问题在线上环境大概只有 约 1% 概率 触发,而且和网络环境高度相关。最后发现锅主要不在 CDN,而是在 Next.js App Router + 静态导出 + RSC fallback 的组合上。
这篇就记录一下整个现象、复现条件和最后落地的阿里云 EdgeScript 方案。
技术栈:Next.js(App Router + output: 'export')
部署方式:构建产物上传到 阿里云 OSS,前面挂一层 阿里云 CDN
静态资源结构概念上类似:
text/homepage/latest/... /homepage/v-<hash>/...
CDN 边缘使用 EdgeScript 做 URL 重写:
/v-xxxxxxx/** → /homepage/<分支>/.../news、/products/... → /homepage/latest/...同时,因为使用 App Router + RSC,Next 在静态导出的时候会为每个路由额外生成一份 RSC Payload 文件(通常就是 index.txt),客户端导航时要用到它:
这份
.txt其实是一个 React Server Components 的序列化结果,用来在客户端更新 DOM,而不是给用户直接看的页面内容。
/news/index.txt 文本页线上偶发现象:
/ 正常。/news:
/news,页面渲染正确。/news/index.txt/news/index.txttext/x-component 那种格式)最开始怀疑是:
但看 Network 之后发现一个关键事实:
出问题那一跳,是浏览器主动请求
/news/index.txt,而不是/news被内部 rewrite 改成了 index.txt。
也就是说,这条错误导航 从一开始就在请求 .txt 文件,CDN 只是老老实实把文件丢回来了。
index.txt 超时这个 bug 最难的地方在于:在正常网络环境下极难复现。后来通过刻意制造弱网条件,终于抓到稳定复现步骤:
/,观察 Network:
index.txt?_rsc=xxx 的请求,这是首页的 RSC 预加载。index.txt?_rsc=... 请求 超时 / 失败。/news。/news/index.txt;/news/index.txt;.txt 的纯文本。结合 console 和 Network,可以看到 Next 在 RSC 请求失败时会打印类似日志:
Failed to fetch RSC payload for /news. Falling back to browser navigation. TypeError: Failed to fetch
说明此时 App Router 的逻辑大致是:
/news 对应的 RSC payload(即某种 .txt 文件);/news/index.txt 这个 URL 做了整页跳转,而不是原始的 /news。综合现象和文档/issue,可以总结出这条 “窄路径”:
使用 App Router + output: 'export' 时:
index.html 外,还会生成一个 RSC payload .txt 文件,供客户端导航使用。Next 客户端在导航或 prefetch 时,会调用一个 fetchServerResponse,把路径转换成 .txt URL 去请求 RSC 数据,例如:
text/news → /news/index.txt?_rsc=xxxx /about/ → /about/index.txt?_rsc=xxxx
如果这个 RSC 请求失败,会打印:
Failed to fetch RSC payload for <url>. Falling back to browser navigation
并进入 “fallback 到浏览器整页导航” 分支。
在静态导出场景下,这个 fallback 的实现存在 bug:
某些情况下,它直接拿刚刚那个 .txt URL(比如 /news/index.txt?_rsc=xxx)作为整页跳转目标,而不是干净的 /news。
于是浏览器就真的去了 /news/index.txt,把 RSC payload 当成普通文档渲染出来。
这条路径触发条件非常苛刻:
.txt URL 的处理 bug;所以在线上表现为典型的:
“现象很固定(总是
/xxx/index.txt),概率极低(约 1%),且和网络波动强相关”。
.txt 导航彻底“封死”根因在 Next.js 的客户端逻辑,但要在短期内完全等一个修复版本并不现实(尤其是生产官网、对外场景)。
工程上更靠谱的思路是:
具体要区分两类请求:
✅ 合法的 RSC 请求:
Rsc: 1 / Accept: text/x-component / _rsc query;fetch / XHR 发起,不应该被 301;text/x-component,并 禁止缓存。❌ 异常的 .txt 导航:
/index.txt 结尾,而且是 document 导航;Rsc: 1 头;/news/,用户只会看到正常页面。最后落地在阿里云 CDN EdgeScript 上的完整脚本如下(已在生产验证):
es# 避免 RSC / 导航变体串缓存 add_rsp_header('Vary', 'RSC, Accept, Sec-Fetch-Mode, Sec-Fetch-Dest', true) add_rsp_header('Vary', 'Next-Router-State-Tree, Next-Router-Prefetch', true) # 兜底 1:无 RSC 头的 index.txt 请求,一律 301 回正常路由 if and(match_re($uri, '/index\.txt$'), not(eq(req_header('Rsc'), '1'))) { add_rsp_header('Location', gsub_re($uri, '/index\.txt$', '/'), true) exit(301) } # ---- RSC 与导航识别(全部单行,避免括号/换行解析问题)---- rsc_h = eq(req_header('Rsc'), '1') rsc_accept = req_header('accept', 're:text/x-component') rsc_qs = req_uri_arg('_rsc') is_rsc = or(rsc_h, rsc_accept, rsc_qs) # 注意:Sec-Fetch-* 要用下划线写法 is_nav = and(req_header('sec_fetch_mode', 'navigate'), req_header('sec_fetch_dest', 'document')) # ---- RSC 子资源走 index.txt;导航走 index.html ---- idx = 'index.html' if and(is_rsc, not(is_nav)) { idx = 'index.txt' } # ---- RSC 响应头(仅 RSC 且非导航)---- if and(is_rsc, not(is_nav)) { add_rsp_header('Content-Type', 'text/x-component; charset=utf-8', false) # 观察期建议保守;若你以后要缓存 RSC,可改成短 TTL 的 s-maxage add_rsp_header('Cache-Control', 'private, no-store', false) } # ---- 放行 /homepage-data/* ---- if match_re($uri, '^/homepage-data/') { # pass } else { # 通用标志 is_ver = match_re($uri, '^/v-[A-Za-z0-9]{7}(/.*)?$') has_ext = req_uri_ext('re:\.[A-Za-z0-9.]+$') is_dir = match_re($uri, '/$') if is_ver { ver_root = or(match_re($uri, '^/v-[A-Za-z0-9]{7}$'), match_re($uri, '^/v-[A-Za-z0-9]{7}/$')) hp = gsub_re($uri, '^/v-', '/homepage/') if ver_root { rewrite(concat(hp, idx), 'break') } if and(not(ver_root), not(has_ext)) { if is_dir { rewrite(concat(hp, idx), 'break') } if not(is_dir) { rewrite(concat(hp, '/', idx), 'break') } } if and(not(ver_root), has_ext) { rewrite(hp, 'break') } } else { latest = concat('/homepage/latest', $uri) if or(eq($uri, '/'), eq($uri, '/index'), eq($uri, '/index/')) { rewrite(concat('/homepage/latest/', idx), 'break') } if and(is_dir, not(has_ext)) { rewrite(concat(latest, idx), 'break') } if and(not(is_dir), not(has_ext)) { rewrite(concat(latest, '/', idx), 'break') } if has_ext { rewrite(latest, 'break') } } }
esif and(match_re($uri, '/index\.txt$'), not(eq(req_header('Rsc'), '1'))) { add_rsp_header('Location', gsub_re($uri, '/index\.txt$', '/'), true) exit(301) }
出现bug时是浏览器直接请求index.txt,此时是没有 RSC 头的,所以判断条件成立后直接 301 回正常路由,也就是假如浏览器直接访问/news/index.txt,直接就重定向到/news,避免浏览器直接访问/news/index.txt后展示其文本内容而非正常的页面。
Vary 头
Vary: RSC, Accept, Sec-Fetch-Mode, Sec-Fetch-Dest, Next-Router-State-Tree, Next-Router-Prefetch兜底 index.txt → 301
match_re($uri, '/index\.txt$') && req_header('Rsc') != '1'.txt 的请求,直接 301 到目录路径(/news/index.txt → /news/)。RSC 请求识别
Rsc: 1 请求头;Accept: text/x-component;_rsc 查询参数。Sec-Fetch-Mode: navigate + Sec-Fetch-Dest: document 判断是不是“整页导航”。idx 变量控制 index.html / index.txt
idx = 'index.html';is_rsc && !is_nav),则切换为 'index.txt';/v-<hash> 和 /homepage/latest 的 rewrite 逻辑都基于 idx,实现:
…/index.html;…/index.txt。RSC 响应头
Content-Type: text/x-component; charset=utf-8;Cache-Control: private, no-store,避免 RSC payload 被 CDN/浏览器缓存串用。修改 EdgeScript 后,用之前的复现步骤重新测试:
index.txt?_rsc=... 预取超时;/news 导航。观察结果:
/news/index.txt 的请求(说明 Next 客户端逻辑没变);301 Location: /news/;/news/,看到的是正常 HTML,而不是 txt 文本。再叠加一段时间线上日志 / 埋点观测:
/xxx/index.txt 的情况不再出现;/xxx/index.txt 请求,也会立即 301 回 /xxx/。至此,可以认为:这个 bug 在“用户可见层面”已经被彻底封印。
.txt RSC payload 是内部数据通道,但在极端情况下可能“泄漏”为页面 URL。/index.txt 一律 301 回目录” 的规则,成本极低,但非常值。