better-auth 事故复盘:为什么在 Next.js 中通过请求头检测 RSC 上下文注定失败
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
导读
本文是基于 better-auth 仓库内.postmortem/rsc-header-detection.md事故复盘文档整理的技术分析。它围绕一个反复出现的问题展开:better-auth 曾在四个 PR 中尝试用RSC请求头判断当前请求是否来自 React Server Component(RSC),以便在 Server Component 上下文中跳过会话刷新(session refresh),但全部失败。读完本文,你将理解 Next.js 内部 Flight 头(Flight headers)为何在所有用户可见的接口上都被剥离、为什么 cookie 探测方案副作用不可接受、为什么单测 mock 无法验证这类运行时假设,以及 better-auth 最终采纳的"让会话刷新幂等而非检测上下文"的设计思路。
背景:better-auth 的 nextCookies 插件与会话刷新
在展开事故细节前,先交代这个检测逻辑在 better-auth 中的位置。better-auth 为 Next.js 提供了nextCookies插件(位于 packages/better-auth/src/integrations/next-js.ts),它的职责是:当服务端调用auth.api.*产生Set-Cookie响应头时,自动把这些 cookie 写入 Next.js 的cookies()存储,从而让 Server Actions 中登录、登出等操作能正常写入 cookie。
而会话刷新逻辑位于 packages/better-auth/src/api/routes/session.ts:当 session 即将过期且满足timeUntilExpiry < updateAge等条件时,getSession会刷新 cookie 缓存与会话 token 的过期时间。这一行为由 should-session-refresh.ts 中的请求级状态getShouldSkipSessionRefresh控制——这正是 RSC 检测试图干预的地方。
问题在于:RSC(Server Component)渲染阶段不能写 cookie。若在 RSC 请求中照常执行会话刷新并尝试写 cookie,就会出现数据库已更新、但 cookie 写不进去的不一致状态。因此 better-auth 需要一种方法识别"当前请求是 RSC",从而跳过刷新。检测RSC: 1请求头,就是被反复尝试、又反复失败的那条路。
核心结论:Flight 头在用户代码运行前就被剥离
事故复盘的结论非常明确:
在 Next.js 中,无法通过读取
RSC请求头来检测 RSC 请求。浏览器在软导航(soft navigation)时确实会发送RSC: 1,但 Next.js 将rsc、next-router-state-tree、next-router-prefetch等视为内部 Flight 头,在用户代码运行之前就把它们从所有用户可访问的表面上剥离。无论是 Server Component 里的headers(),还是 proxy 的request.headers,读到的都是null。
因此,任何基于读取这些请求头的 RSC 检测都是"死路一条"(dead on arrival),而贡献者还在不断重新引入它——这正是复盘文档把"防止复发"列为写作目的之一的原因。
两个剥离点(以 Next.js v16.3.0-canary.36 为准)
复盘文档将行为锚定在 Next.jsv16.3.0-canary.36(commit SHA58e8c0b),指出FLIGHT_HEADERS在两处独立位置被删除,且都发生在用户代码运行之前:
- Proxy 路径:在构建
NextRequestHint之前,位于 Next.js 源码server/web/adapter.ts的L164-L172; headers()路径:在getHeaders()中,位于server/async-storage/request-store.ts的L29-L36。
这是 Next.js 有意的设计:让 RSC 请求与其 HTML 对应请求永远不会被区别对待。该行为在 Next.js 官方文档的 "RSC requests and rewrites" 一节中有明确说明。
两种错误写法
复盘文档给出了两段"错误示范",分别对应被反复引入的两种实现:
// WRONG - always null in RSC, with or without a proxy const isRSC = (await headers()).get("RSC") === "1" // ALSO WRONG - the proxy strips it too, nothing to forward requestHeaders.set("x-better-auth-is-rsc", request.headers.get("RSC"))第一段是从 Server Component 的headers()读取;第二段是试图在 proxy 中把RSC转发到自定义头x-better-auth-is-rsc。两段代码在真实 Next.js 运行时中都会读到null。
复发史:四次 PR 在两种方案间反复横跳
这个逻辑在四个 PR 中反复循环,每个 PR 都在用一个新的真实问题换取另一个真实问题:
| PR | 方案 | 结果 |
|---|---|---|
| #7625 | 引入基于头的检测(RSC: 1) | 首次尝试,头在运行时不可见 |
| #7763 | 改为 cookie 探测:cookies().set()后再delete()以测试可写性 | 信号有效,但无条件使 router cache 失效,引发 #8464 无限刷新循环和 #8828 探测 cookie 泄漏 |
| #9059 | 回退到基于头的检测 | 假定RSC: 1在客户端 flight 请求上存在;在 Next.js 16 上并不存在 |
| #9851 | 外部贡献者假定头只在存在 proxy/middleware 时被剥离,尝试把它转发进自定义头x-better-auth-is-rsc | proxy 同样看不到该头,转发的是null |
为什么两种修复以不同的方式失败
- Cookie 探测(#7763):能作为信号工作,但
cookies().set()每次调用都会使 router cache 失效。副作用不可接受——这正是 issue #8464(无限刷新循环)和 #8828(泄漏的探测 cookie__better-auth-cookie-store)的根源。 - 读请求头(#7625、#9059):零副作用,但头根本不存在。结果是"静默空操作"(silent no-op):代码跑通了,却什么都没做。
为什么单测全都通过:mock 边界的固有局限
最值得警惕的一点是:这些错误的检测逻辑在回归测试中全部通过了。复盘文档指出,next-js.test.ts中的回归测试 mock 了next/headers:
headers: vi.fn(async () => new Headers({ RSC: "1" }))new Headers({ RSC: "1" })是一个真实 Next.js 运行时永远不会产生的输入,因为 Next.js 会先把该头剥离。mock 只会返回测试喂给它的值,所以这套测试只能证明"代码与 mock 一致",永远无法证明"mock 与真实运行时一致"。
这正是 mock 一个边界(boundary)的固有局限:你 stub 的是边界的输出,而不是去执行产生该输出的规则。在真实运行时中,headers()的输出是由"Next.js 先剥离 Flight 头"这条规则决定的,而单测 mock 直接跳过了这条规则。
从当前仓库的 next-js.test.ts 可以看到这种模式仍然存在:should skip refresh in server component context等用例以{ RSC: "1" }作为headers()的 mock 返回值。这些用例验证的是插件逻辑对"假定输入"的响应是否正确,而非"真实运行时是否会产生该输入"——前者是单测的合理职责,后者必须由真实应用或 e2e 检查来覆盖。
如何验证:跑真实 Next.js 应用而非单测
复盘文档给出的验证方式简单而直接:
- 在一个真实 Next.js 应用中,让一个 Server Component 在软导航时打印
(await headers()).get("RSC"),输出是null——尽管 DevTools 中可以看到?_rsc=...请求确实带有RSC: 1头。 - 同理,
proxy.ts中打印request.headers.get("RSC"),同样输出null。
这个对照实验精准地说明了问题:浏览器发出的请求里确实有RSC: 1,但它永远不会以可读形式到达用户代码。DevTools 看到的是网络层的事实,headers()看到的是 Next.js 处理层的事实,二者是两回事。
当前仓库中的检测代码与防护机制
值得注意的是,当前仓库 next-js.ts 中的before钩子仍在读取RSC头:
const isRSC = headersStore.get("RSC") === "1"; const isServerAction = !!headersStore.get("next-action"); if (isRSC && !isServerAction) { await setShouldSkipSessionRefresh(true); }但它包含了两层关键的防御性设计,与前文"检测是错误目标"的教训相呼应:
- 对运行时错误的兜底:
headers()调用被 try/catch 包裹,一旦在非请求作用域(如 monorepo 中 Next.js 之外的 workspace)调用失败,就直接返回,不中断请求流。 - 请求级状态而非全局状态:
setShouldSkipSessionRefresh写入的是 should-session-refresh.ts 中通过defineRequestState创建的请求级状态,默认值为false,不会跨请求泄漏。
同时在 session.ts 中,shouldSkipSessionRefresh只影响"刷新"这一步(timeUntilExpiry < updateAge分支),对 session 的读取本身没有影响;RSC 场景下 session 数据照常返回,只是不执行会触发 cookie 写入的刷新动作。即使检测失效(读到null而把 RSC 误判为普通请求),也只是多执行一次本应幂等的刷新,不会崩溃——这与复盘文档第 4 条教训"让刷新幂等而非检测上下文"的方向一致。
教训总结
复盘文档以五条经验收束,这是全文最值得沉淀的部分:
- 内部 Flight 头对用户不可访问。从
headers()或request.headers读取RSC永远返回null。 - mock 无法验证关于真实模块的假设。测试把
headers()stub 成运行时永远不会产生的返回值,绿灯套件只能证明代码与 mock 一致。依赖运行时剥离规则的 behavior 应放入真实应用或 e2e 检查。 - 两个方向都是死胡同:读头是空操作,proxy 转发是空操作,cookie 探测则有不可接受的副作用。
- 检测本身就是错误目标。该检测存在的唯一目的是在 cookie 无法写入时跳过会话刷新。正确做法是让刷新幂等,使它在 cookie 不可写时也无害,而不是去检测上下文。
- 这是反复出现的贡献者假设。review 中遇到读取
RSC头的 PR 时,应链接本文档。
预防措施
复盘文档给出了两条可执行的预防动作:
- 在检测代码处添加指向本事故复盘文档的代码注释,让后续维护者第一时间看到前因后果;
- 拒绝那些从
headers()或 proxy 读取RSC/next-router-*头的 PR,并在评审意见中附上上述两处 Next.js 源码位置作为依据。
这两条措施配合本文档,构成一个完整的"防复发"闭环:注释负责在代码层面留痕,评审规则负责在流程层面拦截。
结语
这次事故的价值不在于"哪个方案错了",而在于它揭示了三层方法论问题:框架内部头的可见性边界(Next.js 有意让 RSC 请求与其他请求不可区分)、测试与运行时之间的真实性鸿沟(mock 只能证明代码与 mock 一致)、以及"绕开问题而非解决问题"的诱惑(cookie 探测与头检测都在试图检测一个不该被检测的上下文)。对于所有在 Next.js 之上做框架集成的开发者,这份复盘都是一份值得保存的现场记录;对 better-auth 而言,它也是防止同类 PR 再次合入的第一道防线。
【免费下载链接】better-authThe most comprehensive authentication framework项目地址: https://gitcode.com/GitHub_Trending/be/better-auth
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考