better-auth 事故复盘:为什么在 Next.js 中通过请求头检测 RSC 上下文注定失败
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 ComponentRSC以便在 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 检测试图干预的地方。问题在于RSCServer 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.36commit SHA58e8c0b指出FLIGHT_HEADERS在两处独立位置被删除且都发生在用户代码运行之前Proxy 路径在构建NextRequestHint之前位于 Next.js 源码server/web/adapter.ts的L164-L172headers()路径在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-rscproxy 同样看不到该头转发的是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/headersheaders: 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),仅供参考