2025-12-12
前端
00

目录

Next.js App Router + 阿里云 OSS/CDN 下偶发 index.txt 导航问题排查记录
一、背景:部署形态 & 路由结构
二、现象:偶发跳到 /news/index.txt 文本页
三、关键复现步骤:弱网 + 预取 index.txt 超时
四、根因:App Router + 静态导出 + RSC fallback 的窄路径 bug
五、设计目标:在边缘把 .txt 导航彻底“封死”
六、最终 EdgeScript 实现
关键代码
简要说明
七、验证:弱网场景压测 + 线上观测
八、小结

Next.js App Router + 阿里云 OSS/CDN 下偶发 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
    • 大部分时候:一切正常,URL 是 /news,页面渲染正确。
    • 极少数时候
      • 地址栏会突然变成 /news/index.txt
      • Network 里能看到发起的是 document 请求 /news/index.txt
      • 页面原封不动展示 RSC 文本内容(text/x-component 那种格式)

最开始怀疑是:

  • CDN 缓存串味
  • Vary 头没加好
  • 自己的重写规则有 bug

但看 Network 之后发现一个关键事实:

出问题那一跳,是浏览器主动请求 /news/index.txt,而不是 /news 被内部 rewrite 改成了 index.txt。

也就是说,这条错误导航 从一开始就在请求 .txt 文件,CDN 只是老老实实把文件丢回来了。


三、关键复现步骤:弱网 + 预取 index.txt 超时

这个 bug 最难的地方在于:在正常网络环境下极难复现。后来通过刻意制造弱网条件,终于抓到稳定复现步骤:

  1. 打开首页 /,观察 Network:
    • 会看到首页 HTML、CSS、JS 正常加载;
    • 同时还有一个 index.txt?_rsc=xxx 的请求,这是首页的 RSC 预加载
  2. 在首页资源还在加载、网络状态开始变差(比如限速 / 丢包)时:
    • 让这个 index.txt?_rsc=... 请求 超时 / 失败
  3. 就在这个时刻,立刻点击导航 /news
  4. 结果:
    • 浏览器发出的 document 请求是 /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 的逻辑大致是:

  1. 试图请求 /news 对应的 RSC payload(即某种 .txt 文件);
  2. 请求失败(超时 / 网络错误);
  3. 进入 「Failed to fetch RSC payload → fallback 到浏览器整页导航」 的分支;
  4. 但在静态导出模式下,它错误地拿 /news/index.txt 这个 URL 做了整页跳转,而不是原始的 /news

四、根因:App Router + 静态导出 + RSC fallback 的窄路径 bug

综合现象和文档/issue,可以总结出这条 “窄路径”:

  1. 使用 App Router + output: 'export' 时:

    • 每条路由除了 index.html 外,还会生成一个 RSC payload .txt 文件,供客户端导航使用。
  2. Next 客户端在导航或 prefetch 时,会调用一个 fetchServerResponse把路径转换成 .txt URL 去请求 RSC 数据,例如:

    text
    /news → /news/index.txt?_rsc=xxxx /about/ → /about/index.txt?_rsc=xxxx
  3. 如果这个 RSC 请求失败,会打印:

    Failed to fetch RSC payload for <url>. Falling back to browser navigation

    并进入 “fallback 到浏览器整页导航” 分支。

  4. 在静态导出场景下,这个 fallback 的实现存在 bug
    某些情况下,它直接拿刚刚那个 .txt URL(比如 /news/index.txt?_rsc=xxx)作为整页跳转目标,而不是干净的 /news
    于是浏览器就真的去了 /news/index.txt,把 RSC payload 当成普通文档渲染出来。

这条路径触发条件非常苛刻:

  • 必须是某次导航 / 预取需要重新请求 RSC;
  • 且这次请求遭遇网络错误、超时或其他异常;
  • 再加上静态导出模式下 .txt URL 的处理 bug;

所以在线上表现为典型的:

现象很固定(总是 /xxx/index.txt),概率极低(约 1%),且和网络波动强相关”。


五、设计目标:在边缘把 .txt 导航彻底“封死”

根因在 Next.js 的客户端逻辑,但要在短期内完全等一个修复版本并不现实(尤其是生产官网、对外场景)。
工程上更靠谱的思路是:

  1. 识别“正常的 RSC 请求”和“异常的整页导航请求”
  2. 对于后者,在 CDN 边缘层强制纠偏(301 回正常路由)。

具体要区分两类请求:

  • ✅ 合法的 RSC 请求:

    • Rsc: 1 / Accept: text/x-component / _rsc query;
    • 通常是 fetch / XHR 发起,不应该被 301;
    • 应该返回 text/x-component,并 禁止缓存
  • ❌ 异常的 .txt 导航:

    • URI 以 /index.txt 结尾,而且是 document 导航
    • 一般不会带 Rsc: 1 头;
    • 一旦出现,统一 301 到对应的目录 /news/,用户只会看到正常页面。

六、最终 EdgeScript 实现

最后落地在阿里云 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') } } }

关键代码

es
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) }

出现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
    • 避免不同类型的请求(RSC / document / prefetch 变体)在 CDN 缓存里串在一起。
  • 兜底 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'
    • 如果是 RSC 子资源请求(is_rsc && !is_nav),则切换为 'index.txt'
    • 后面所有 /v-<hash>/homepage/latest 的 rewrite 逻辑都基于 idx,实现:
      • 导航:…/index.html
      • RSC:…/index.txt
  • RSC 响应头

    • 为 RSC 请求强制加上 Content-Type: text/x-component; charset=utf-8
    • 并设置 Cache-Control: private, no-store,避免 RSC payload 被 CDN/浏览器缓存串用。

七、验证:弱网场景压测 + 线上观测

修改 EdgeScript 后,用之前的复现步骤重新测试:

  1. Chrome DevTools 限速(如 Slow 3G),或者手动制造网络抖动;
  2. 让首页 index.txt?_rsc=... 预取超时;
  3. 立刻点击 /news 导航。

观察结果:

  • 浏览器仍然可能短暂发出 /news/index.txt 的请求(说明 Next 客户端逻辑没变);
  • 但这次会被边缘脚本匹配到 兜底规则,直接返回 301 Location: /news/
  • 最终页面停留在 /news/,看到的是正常 HTML,而不是 txt 文本。

再叠加一段时间线上日志 / 埋点观测:

  • 页面 URL 落在 /xxx/index.txt 的情况不再出现;
  • 即便偶然有 /xxx/index.txt 请求,也会立即 301 回 /xxx/

至此,可以认为:这个 bug 在“用户可见层面”已经被彻底封印


八、小结

  1. App Router + 静态导出 + RSC 本身还有一些 corner case,尤其是 RSC 请求失败后的 fallback
  2. .txt RSC payload 是内部数据通道,但在极端情况下可能“泄漏”为页面 URL。
  3. 对于生产官网这类场景,在 CDN / EdgeScript 层加上一条 “非 RSC 的 /index.txt 一律 301 回目录” 的规则,成本极低,但非常值。