Next.js RSC 的数据传递与缓存

固定 Next.js 16.3.3 配置,用独立 fixture 的 HTML、Flight 和写入请求核验 DTO、两类缓存、权限拒绝、版本冲突,以及保存成功后刷新失败的处理边界。

·48 minsecurityNext.js
把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

文章页从服务端拿到一条记录:标题、正文、版本,还有审核备注和未公开草稿。页面只显示标题,开发者把完整记录传给编辑组件,方便以后添加功能。读者看不见备注,却可能在页面响应里找到它。本轮最小实验实际重现了这个错误:客户端组件没有渲染内部字段,初始响应仍包含两个预先设置的私有标记。

RSC 让服务端负责一部分渲染和数据读取,开发者仍要决定哪些值可以进入浏览器。这个决定发生在对象构造和传参时,不能靠组件文件名、界面是否显示某个字段,或一个 TypeScript 类型声明完成。继续沿着编辑按钮追踪,写入入口还要验证调用者、输入版本和更新权限;保存后的下一次读取则涉及另一套缓存约定。

本文使用独立的 Next.js 16.3.3 fixture,Node 为 v25.5.0。所有数据均为假数据,服务只监听本机回环地址,未接入应用数据库、认证接口或环境文件。运行器启动前断言 Next 为 16.3.3、React 与 React DOM 均为 19.2.8,在临时目录执行生产构建和启动。修订回归保留原有检查,累计执行二十六项 HTTP 检查及十三项客户端状态检查。下文提供完整生成与执行程序,包含全部 fixture 文件,不依赖另外下载的源码。

固定配置后再讨论缓存

本例明确关闭 Cache Components,使用 Node 运行时、App Router 和生产模式。公开内容通过带标签的 fetch 加入跨请求缓存;页面调用 connection() 后按请求渲染。构建设置只开放一个工作 CPU,目的是控制实验资源,与数据边界本身无关。

export default {
  cacheComponents: false,
  experimental: { cpus: 1 }
};

Next.js 的随包文档位于 node_modules/next/dist/docs/。写代码之前核对了 Server/Client Components、Route Handlers、数据安全、关闭 Cache Components 时的缓存语义,以及 revalidateTag。证据目录保留这些文件的副本,版本依据来自本地 16.3.3 包。公开的 数据安全指南和 组件指南便于读者查阅,但线上页面会滚动更新,不能替代固定版本的随包材料。

选择这种模式是为了把请求内复用与共享数据缓存分开观察。若你已启用 Cache Components,应按照对应指南使用 use cache 等接口,重新确定配置及失效行为。本例没有运行这一模式。随包 unstable_cache 文档也提示 Next.js 16 推荐迁移到新接口,所以实验没有顺手引入它来解释当前示例。

生产构建可以避开开发模式热更新、预取和调试行为对观察的干扰,但一次本地构建仍不代表生产部署认证。多实例缓存、CDN、代理和浏览器导航缓存都不在此次进程拓扑里。本文中“下一次读取看到新值”指向这套明确配置下的本地请求,后面会给出相应响应编号。

从存储对象构造公开对象

假服务的原始记录包含公开文章字段,以及 owner、published、internalNote、draftText。其中审核备注和草稿内容分别设置为容易搜索的 ASCII 标记,避免转义或中文编码干扰判断。假服务模拟一个独立数据源;Server Component 读取它,没有绕回自身的 Route Handler。

真正跨越边界之前,数据访问函数逐项构造 DTO:

export function dto(a) {
  if (!a.published) throw Error('not public');
  return {
    id: a.id,
    title: a.title,
    body: a.body,
    version: a.version
  };
}

显式选择字段有两个作用。新增内部字段不会自动进入返回对象;公开状态检查也集中在这个出口。本例只展示已经发布的固定记录,拒绝未发布记录的分支存在于代码中,未单独注入草稿请求验证。正式页面通常应把不可公开或不存在的资源映射为合适的错误响应,不能直接沿用这里为缩小实验而抛出的普通异常。

server-only 标记防止客户端模块误导入数据访问代码,却不检查你返回了什么。TypeScript 的 PublicArticle 类型同样不会在运行时删除对象已有属性。若数据库返回完整对象,给它赋一个更窄的类型,再把原对象传下去,序列化仍可能带上内部字段。对象展开后删几个字段也容易在新增字段时漏处理,因此这里采用允许字段清单。

公开 DTO 中保留正文,是因为文章页确实要展示它。本例让 React 把正文当纯文本渲染,不支持 Markdown HTML 或富文本;真实站点还需独立处理内容渲染安全。不要把字段隔离检查通过,推导成任意正文都可按原始 HTML 注入页面。它们对应不同的输入与输出边界。

客户端编辑器只需要标题和版本就能提交本例修改,正文也可继续留在服务端渲染结果里。实验把公开 DTO 整体交给编辑器,便于与错误的整条记录传参作直接对照。这不等于“公开字段必须全部重复传给客户端组件”。大型正文页面应进一步按交互需求拆分 props,减少重复序列化和客户端状态规模。

用响应证明没有渲染的字段也会泄漏

正常页面先对原始记录调用 dto,再把结果传给 Editor。错误页面 /unsafe 则把 read() 的原始结果直接传进去。Editor 的渲染逻辑相同,只展示标题输入框、保存按钮和状态信息,没有访问审核备注或草稿。

运行器分别请求普通 HTML 和带 RSC: 1 头的 Flight 响应,并保存响应头、状态码及完整响应体。正常页面的两类响应没有发现 PRIVATE_NOTE_729、PRIVATE_DRAFT_816 或 internalNote;错误页面的响应实际包含前两个标记。下文运行器会保存检查清单和完整响应,读者运行后可在本地比较正常页面与错误页面。

这个反例的关键是传参对象。RSC payload 会包含 Server Component 传给 Client Component 的 props,因此“不使用该属性”并不能把它留在服务端。初始 HTML 中还可能嵌入用于恢复组件树的数据,只看渲染后的 DOM 会漏掉这部分。排查泄漏时应同时查看页面源码与网络响应,而不是根据浏览器元素面板里有没有备注节点下结论。

这是一项针对已知标记的回归检查。它能证明当前测试记录的这些字段未出现在检查过的响应里,不能证明项目中不存在其他敏感字段、编码形式或出口。正式测试应从数据分类出发,为需要保密的属性建立检查样本,并覆盖预取、错误响应和其他公开接口。本例保留错误路由是为了教学复现,不能把 /unsafe 部署到真实站点。

'use client' 决定模块依赖边界。客户端模块直接导入的依赖要按客户端执行环境考虑;服务端父组件生成的内容作为 children 传给它,则有不同的组合方式。视觉上的上下嵌套不足以推断代码运行位置。另外,客户端组件可能参与首屏服务端预渲染,在函数顶层读 window 仍可能出错。实验的 Editor 把交互放进事件处理函数,渲染本身不依赖浏览器专有对象。

两次读取同时看出两种缓存范围

实验设置两个观测点。第一个是服务端 memo 函数:它用 React 的 cache() 包裹,内部生成随机 UUID 并打印执行日志。同一页面先后调用两次,输出两个返回值。第二个是外部假服务的读取计数,记录公开文章被实际获取了几次。

export const memo = cache(async () => {
  const value = randomUUID();
  console.log('MEMO_EXEC', value);
  return value;
});

const response = await fetch(process.env.LAB_SOURCE, {
  cache: 'force-cache',
  next: { tags: ['article'] }
});

第一次页面请求的两个 memo 值相同,第二次请求内部也相同,但两次请求的值不同。与此同时,假服务读取计数第一次为一,第二次仍为一。于是可以分别确认:React 包装函数在当前渲染请求内复用结果;公开文章的 fetch 结果跨越了两个请求。这里没有用同一个 fetch 的自动去重现象来冒充 React cache() 实验,UUID 观测独立于网络读取。

React 官方 cache 文档说明了请求范围与参数比较规则。实际代码还要确保调用方共享同一个包装后的函数。在两个组件里分别调用 cache(fn) 会创建不同的包装,不能期待它们因为内部函数相似就共享记录。参数若包含临时新建的对象,也要注意引用差异对命中的影响;本例使用无参数函数,刻意排除了这项变量。

请求内去重可以减少同一页面多个区块重复读取同一数据,却不会让下一位读者复用结果。跨请求缓存能减少更多数据源调用,但必须选择缓存键、失效条件与可见范围。对于用户收藏状态,仅以文章 ID 缓存会混淆不同用户;即使加入用户 ID,权限撤销后的时效仍需设计。公开正文与私人状态分开读取,通常更容易解释缓存能服务哪些请求。

实验把完整假记录缓存在服务端,再通过 DTO 限定输出。这没有在当前响应中泄漏标记,但生产代码仍可让数据层只读取必要字段,减少服务端缓存持有的敏感信息。字段最小化和缓存作用域是两件独立决定:缩小对象不代表缓存授权正确,缓存按身份区分也不代表传给浏览器的字段都合适。

写入入口仍需鉴权

本例使用 POST /api/article,因为独立 HTTP 请求便于保存和重放输入输出,也能清楚显示状态码。Server Action 适合与框架表单及更新流程整合,RSC 并不要求写入只能通过 Action。两种入口都应检查身份和目标权限,不能把按钮隐藏或“函数只在服务端执行”当成授权结果。

fixture 使用固定的 x-lab-session 值映射假身份。缺失身份返回四〇一,模拟读者身份返回四〇三,只有 author 可编辑固定文章。这个请求头可由调用者自行设置,属于实验替身,不能用作真实认证方案。应用接入时要从已验证会话取得身份,在可信数据层检查目标文章归属;不能照抄请求头即作者的约定。

Route Handler 校验 JSON 是否可解析、标题是否为空或超过八十字符、版本是否为正的安全整数,并拒绝约定字段之外的输入。存在 Origin 时还检查是否与当前请求来源一致。真实 Cookie 认证方案需要完整的跨站请求防护及部署代理约定;本实验的来源检查不是对此的全覆盖验证。

运行器实际验证了无身份、错误身份和空标题三种拒绝,状态分别为四〇一、四〇三和四〇〇。修订回归新增 null、数组、字符串、数字、空对象、零版本、额外字段和损坏 JSON,均返回四〇〇,数据源写入计数不变。来源拒绝分支仍未单独执行。技术记录区分这些情况,避免把“代码里写了分支”汇报成“已经逐项测试”。这样的区分比一行模糊的“鉴权通过”更有审查价值。

权限通过后,假服务比较请求版本与存储版本,再更新标题并增加版本。本轮版本一更新到二;拿旧版本一再次提交,得到四〇九,存储不接受旧编辑覆盖新标题。比较和更新发生在同一个假服务进程里,其间没有异步等待,因此这组顺序请求能重现乐观并发的冲突语义。它没有验证真实数据库的并发事务,应在正式实现中使用带版本条件的原子更新,并检查影响行数。

版本冲突时,客户端应保留尚未提交的标题,让用户比较当前服务器版本后决定合并。自动用最新版本号重新发送旧标题,会绕过冲突保护。对用户而言,冲突是在提示另一次修改已经发生;重新加载并清空输入会把本可保留的工作丢掉。

保存提交与界面更新各有结果

假数据源提交并返回可核验的标题与版本后,Route Handler 先调用 revalidateTag('article', { expire: 0 }) 失效标签,再向浏览器返回 committed: true 回执和缓存处理结果。正常路径上的下一次读取因而不会继续使用这条旧缓存。随包 revalidateTag 文档区分了立即到期与 max 的旧值重验证行为:本例要观察写后读取,所以选择前者;没有使用已弃用的单参数写法。

第一次更新成功后,下一次页面请求显示 Updated title,假服务读取次数从一增到二,证明这次失效导致重新读取。这里的数据标签只有一篇固定文章。真实站点可以按文章 ID 划分标签,避免修改一篇触发整个内容库失效;列表或搜索服务若有独立缓存,也要由对应所有者处理,当前标签不会替你更新所有外部副本。

第二次写入把存储版本升到三。运行器在提交后打开假服务的读取故障开关,下一次页面请求返回五〇〇,而存储版本仍为三。恢复读取后,页面显示已提交的标题。这组响应分别在 11-save.txt、12-refresh-failed.txt 与 13-refresh-recovered.txt,构成“写成功、读失败、读恢复”的完整证据。

客户端此时应保留已知提交版本,提示保存成功但刷新失败,并允许重试读取。若界面把五〇〇笼统显示成保存失败,用户可能再次提交同一动作。对于标题覆盖,版本比较会帮助识别陈旧请求;对于发通知、扣款等附带副作用的操作,还要有业务幂等与结果查询,不能依赖页面刷新判断是否提交。

若写入请求断开,或者已经收到响应头却无法解析正文,客户端都缺少可信的提交回执。修订版把 fetch 与读取回执放进同一确认流程,只有文章 ID、标题、下一版本及 committed 标志符合请求,才更新本地版本。无效回执保留标题与版本、显示结果未知并停止刷新。界面停用再次提交,等待结果查询;本例尚未实现查询与解除停用的交互,不能靠再次点击碰运气。缓存失效若抛错,Handler 保留已确认提交,返回 cacheInvalidated:false;客户端显示缓存待刷新,不把业务成功翻成失败。持久化失效补偿仍需真实服务实现。

fixture 的 Editor 在保存后发起页面 GET,检查响应是否成功,并在表单内保留返回版本。它没有把下载的 HTML 替换进当前 DOM,也没有运行浏览器水合或点击测试。因此这里的“刷新成功”只表示服务器返回新页面响应,不能宣称已经验证浏览器导航更新。正式 UI 可结合路由刷新或重新请求公开 DTO,并根据实际返回版本更新可见状态;页面更新失败的文案与重试入口也应通过浏览器测试确认。

从空输入到损坏回执的边界回归

JSON 能解析为 null,并不意味着它符合写入契约。入口先确认顶层是非空、非数组对象,再检查标题和版本,因此不会在 null 上读取 title。修订实验将八种无效输入发送到真实本地 Route Handler,全部取得四〇〇;假服务写入次数仍为三,证明这些拒绝发生在调用数据源之前。

回执也要按结构核验。假数据源分别在版本四到八提交后返回空值、损坏 JSON、错误文章 ID、错误版本和未提交标志,入口均返回五〇二及 outcome_unknown。存储版本确实已经变化,所以这些响应不能引导调用者把同一修改当成未发生,再用新意图重发。没有核验成功时,Handler 不宣称缓存已经失效。

客户端与输入校验共用正文内嵌的 contract 模块。十三项内存回归使用真实 Response 或读取失败替身,检查损坏正文、空回执、缺字段、错误版本、错误文章、二〇二、五〇〇、冲突、正常成功、缓存待处理以及传输异常。无法确认时返回原输入版本、保留标题,refresh 为 false;正常回执才产生新版本。Editor 直接调用这个函数,测试没有另写一套近似判断,但浏览器水合与点击仍未执行。

把复现边界留给接手的人

运行器启动本地假数据源,分配回环端口,复制 fixture 后链接已有依赖,再依次构建、启动和发送请求。子进程只得到实验需要的环境变量,没有读取应用环境文件或使用 DATABASE_URL。结束时关闭两个服务,删除本轮临时目录的 .next 和 node_modules 链接,保留源码及证据,不删除已有项目依赖。证据中出现的内部标记、作者名和文章都是专为本次实验定义的值。

重新运行可能得到不同端口和 UUID,所以断言检查相等关系、计数及状态,不写死随机值。复现需要本机有对应依赖,运行器中的仓库依赖路径也要按机器调整。使用其他 Next.js 或 React 版本时,应重新构建并保留结果,不能把这次通过记录沿用到新的依赖组合。

两个编辑窗口如何使用版本号

把顺序冲突实验放进实际界面,可以得到一条容易检查的交互约定。两个窗口都打开版本一,各自在本地修改标题。第一个窗口提交后取得版本二,第二个窗口仍携带版本一,所以写入入口返回冲突。第二个窗口应保留本地输入,再读取服务器版本二,让用户比较两次修改。这里描述的是由已测版本比较推导的交互流程,没有运行双窗口浏览器实验。

如果服务器版本二只是修正了正文,而第二个窗口只改标题,产品可以提供字段级合并。不过合并应依据“基线、当前服务器值、本地候选”三份内容,不能只看字段名称不同就跳过版本前提。用户可能同时改变了发布状态或编辑权限,合并后的提交仍要重新鉴权,并带上所依据的服务器版本。乐观并发控制负责发现变化,产品规则负责决定哪些变化可以合并。

正文缓存还会影响撤回。文章从发布改为草稿后,旧缓存中仍可能保存公开状态与正文。只在写入入口更改存储,读取函数继续命中旧记录,就可能让后续读者看到已经撤回的内容。对于这种操作,失效失败的处理优先级应高于普通标题延迟;可以把发布状态放进具有相应一致性保障的访问检查,或者选择明确的撤回生效窗口。本例没有注入撤回,因此这段属于设计取舍。

缓存标签也不是权限证明。调用者只要知道文章标签,不应就获得触发任意失效的权限;公开提供重验证接口时,仍要验证调用来源与目标。反过来,按文章正确失效也不能清除已经下载到某个浏览器的内容。权限设计必须区分后续访问与既有副本,不能向编辑者承诺撤回会让读者机器上的旧页面消失。

页面刷新后,表单状态还有一个版本同步问题。客户端组件若只用初始 props 初始化输入框,后续服务器数据变化时,已有本地输入未必随之变化;无条件同步又可能覆盖尚未保存的编辑。产品应区分空闲、编辑中、已提交和冲突状态:空闲时可接受服务器新值,编辑中保留输入并提示新版本,提交后以回执版本为依据。fixture 用状态保存回执版本,完整的远端变更通知和合并界面仍留给正式应用实现。

这条数据流现在有可指认的交接点:原始记录由服务端读取,DTO 决定响应字段,写入入口核验假身份和版本,成功回执记录提交,下一次读取通过缓存失效取得新内容。实际项目接入时,最先替换的是假认证和假存储,再补浏览器及并发验证。保留本例的响应断言,可以在后续增加字段、拆分组件或调整缓存时及时发现边界变化。

完整 fixture 与执行方法

下方 run.mjs 包含全部 fixture 文件、假数据源及回归步骤。使用 Node v25.5.0,在独立目录安装对应直接依赖后运行;也可把已具备相同依赖的目录作为第一个参数,启动前会核对实际版本。

mkdir rsc-boundary-lab
cd rsc-boundary-lab
mkdir runtime
cat > runtime/package.json <<'JSON'
{
  "private": true,
  "dependencies": {
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}
JSON
npm install --prefix runtime --no-audit --no-fund
node run.mjs

程序所在目录会生成当前 fixture 副本,每轮在 after/run-时间戳保存独立源码与原始响应。临时构建目录的 .next 和依赖符号链接会在结束时清理,源码继续保留。固定版本只覆盖直接依赖,传递依赖仍可能因安装时间变化;本文回归使用本地既有依赖,没有下载或启动 Docker。

import fs from 'node:fs';import path from 'node:path';import os from 'node:os';import http from 'node:http';import {spawn} from 'node:child_process';import {fileURLToPath,pathToFileURL} from 'node:url';
import assert from 'node:assert/strict';
const evidenceRoot=path.dirname(fileURLToPath(import.meta.url));
const repo=path.resolve(process.argv[2]||'runtime');
const versions={next:'16.3.3',react:'19.2.8','react-dom':'19.2.8'};
for(const [name,expected] of Object.entries(versions)) {
  const actual=JSON.parse(fs.readFileSync(path.join(repo,'node_modules',name,'package.json'))).version;
  assert.equal(actual,expected,'dependency version: '+name);
}
const evidence=path.join(evidenceRoot,'after','run-'+Date.now());
fs.mkdirSync(evidence,{recursive:true});
const dir=fs.mkdtempSync('/tmp/logz-rsc-');
const save=(name,value)=>fs.writeFileSync(path.join(evidence,name),typeof value==='string'?value:JSON.stringify(value,null,2));
let article={id:'article-1',title:'Initial title',body:'Public body fixture',version:1,published:true,owner:'author',internalNote:'PRIVATE_NOTE_729',draftText:'PRIVATE_DRAFT_816'};let reads=0,writes=0,fail=false,writeMode='normal';
const service=http.createServer(async(req,res)=>{let raw='';for await(const b of req)raw+=b;res.setHeader('content-type','application/json');
 if(req.url==='/fail'){fail=JSON.parse(raw).fail;res.end('{}');return;}
 if(req.method==='GET'){reads++;if(fail){res.writeHead(503);res.end('{"error":"injected"}');return;}res.end(JSON.stringify(article));return;}
 const input=JSON.parse(raw);writes++;if(input.version!==article.version){res.writeHead(409);res.end('{"error":"version_conflict"}');return;}
 article={...article,title:input.title,version:article.version+1};
 const receipt={id:article.id,title:article.title,version:article.version,committed:true};
 if(writeMode==='null'){res.end('null');return;}
 if(writeMode==='malformed'){res.end('{');return;}
 if(writeMode==='wrong-id'){res.end(JSON.stringify({...receipt,id:'other'}));return;}
 if(writeMode==='wrong-version'){res.end(JSON.stringify({...receipt,version:receipt.version+5}));return;}
 if(writeMode==='uncommitted'){res.end(JSON.stringify({...receipt,committed:false}));return;}
 res.end(JSON.stringify(receipt));
});await new Promise(r=>service.listen(0,'127.0.0.1',r));const source='http://127.0.0.1:'+service.address().port;
const files = {
  "package.json": "{\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"next build --webpack\",\n    \"start\": \"next start\"\n  },\n  \"dependencies\": {\n    \"next\": \"16.3.3\",\n    \"react\": \"19.2.8\",\n    \"react-dom\": \"19.2.8\"\n  }\n}",
  "next.config.mjs": "export default {cacheComponents:false,experimental:{cpus:1}};\n",
  "app/layout.jsx": "export default function Layout({children}){return <html><body>{children}</body></html>}\n",
  "app/lib.js": "import 'server-only';\nimport {cache} from 'react';\nimport {randomUUID} from 'node:crypto';\nexport const memo=cache(async()=>{const value=randomUUID();console.log('MEMO_EXEC',value);return value;});\nexport async function read(){const r=await fetch(process.env.LAB_SOURCE,{cache:'force-cache',next:{tags:['article']}});if(!r.ok)throw Error('source unavailable');return r.json();}\nexport function dto(a){if(!a.published)throw Error('not public');return {id:a.id,title:a.title,body:a.body,version:a.version};}\n",
  "app/Editor.jsx": "'use client';\nimport {useState} from 'react';\nimport {submitEdit} from './contract.mjs';\nexport default function Editor({article}) {\n  const [title,setTitle]=useState(article.title);\n  const [version,setVersion]=useState(article.version);\n  const [message,setMessage]=useState('');\n  const [busy,setBusy]=useState(false);\n  const [uncertain,setUncertain]=useState(false);\n  async function save(event) {\n    event.preventDefault();\n    if (busy || uncertain) return;\n    setBusy(true);\n    try {\n      const result=await submitEdit(fetch,{title,version});\n      setMessage(result.message);\n      if(result.phase==='unknown') setUncertain(true);\n      if(result.phase!=='confirmed') return;\n      setVersion(result.version);\n      if(!result.refresh) return;\n      try {\n        const response=await fetch('/?after='+result.version);\n        if(!response.ok) throw Error('refresh failed');\n        setMessage('Saved version '+result.version+'; page response refreshed');\n      } catch { setMessage('Saved version '+result.version+'; refresh failed'); }\n    } finally { setBusy(false); }\n  }\n  return <form onSubmit={save}>\n    <input value={title} disabled={busy} onChange={e=>setTitle(e.target.value)}/>\n    <button disabled={busy||uncertain}>Save fixture</button><output>{message}</output>\n  </form>;\n}\n",
  "app/page.jsx": "import {connection} from 'next/server';\nimport {read,dto,memo} from './lib';import Editor from './Editor';\nexport default async function Page(){await connection();const article=dto(await read());const a=await memo(),b=await memo();return <main><h1>{article.title}</h1><p>{article.body}</p><p id=\"memo\">{a+'|'+b}</p><Editor article={article}/></main>}\n",
  "app/unsafe/page.jsx": "import {connection} from 'next/server';import {read} from '../lib';import Editor from '../Editor';export default async function Page(){await connection();return <Editor article={await read()}/>}\n",
  "app/api/article/route.js": "import {revalidateTag} from 'next/cache';\nimport {isInput,isReceipt} from '../../contract.mjs';\nexport async function POST(req) {\n  const actor=req.headers.get('x-lab-session');\n  if(!actor) return Response.json({error:'unauthenticated'},{status:401});\n  if(actor!=='author') return Response.json({error:'forbidden'},{status:403});\n  const origin=req.headers.get('origin');\n  if(origin&&origin!==new URL(req.url).origin)\n    return Response.json({error:'origin'},{status:403});\n  let input;\n  try { input=await req.json(); }\n  catch { return Response.json({error:'json'},{status:400}); }\n  if(!isInput(input)) return Response.json({error:'validation'},{status:400});\n  let receipt;\n  try {\n    const response=await fetch(process.env.LAB_SOURCE, {method:'POST',\n      headers:{'content-type':'application/json'},\n      body:JSON.stringify({title:input.title.trim(),version:input.version}),\n      cache:'no-store',signal:AbortSignal.timeout(2000)});\n    const result=await response.json();\n    if(response.status===409&&result?.error==='version_conflict')\n      return Response.json({error:'version_conflict'},{status:409});\n    if(response.status!==200||!isReceipt(result,input)) throw Error('invalid receipt');\n    receipt={id:result.id,title:result.title,version:result.version,committed:true};\n  } catch { return Response.json({error:'outcome_unknown'},{status:502}); }\n  let cacheInvalidated=true;\n  try { revalidateTag('article',{expire:0}); }\n  catch { cacheInvalidated=false; }\n  // Commit confirmed, invalidation attempted, then return the browser receipt.\n  return Response.json({...receipt,cacheInvalidated});\n}\n",
  "app/contract.mjs": "export function isInput(value) {\n  return value !== null && typeof value === 'object' && !Array.isArray(value)\n    && typeof value.title === 'string' && value.title.trim().length > 0\n    && value.title.length <= 80 && Number.isSafeInteger(value.version)\n    && value.version >= 1 && value.version < Number.MAX_SAFE_INTEGER\n    && Object.keys(value).every(key => ['title','version'].includes(key));\n}\nexport function isReceipt(value, input) {\n  return value !== null && typeof value === 'object' && !Array.isArray(value)\n    && value.committed === true && value.id === 'article-1'\n    && value.title === input.title.trim() && value.version === input.version + 1;\n}\nexport async function submitEdit(fetcher, input) {\n  const unknown = {phase:'unknown',version:input.version,title:input.title,\n    refresh:false,message:'Write result unknown; query before retry'};\n  try {\n    const response = await fetcher('/api/article', {method:'POST',\n      headers:{'content-type':'application/json','x-lab-session':'author'},\n      body:JSON.stringify(input)});\n    const result = await response.json();\n    if ([400,401,403,409].includes(response.status)\n      && result !== null && typeof result === 'object' && !Array.isArray(result)\n      && typeof result.error === 'string') {\n      return {...unknown,phase:'rejected',message:result.error};\n    }\n    if (response.status !== 200 || !isReceipt(result,input)\n      || typeof result.cacheInvalidated !== 'boolean') return unknown;\n    return {phase:'confirmed',version:result.version,title:input.title,\n      refresh:result.cacheInvalidated,\n      message:result.cacheInvalidated ? 'Saved version '+result.version+'; refreshing'\n        : 'Saved version '+result.version+'; cache refresh pending'};\n  } catch { return unknown; }\n}\n"
};
for(const [name,body]of Object.entries(files)){fs.mkdirSync(path.dirname(path.join(dir,name)),{recursive:true});fs.writeFileSync(path.join(dir,name),body);}
fs.symlinkSync(repo+'/node_modules',dir+'/node_modules','dir');
fs.mkdirSync(evidence+'/fixture',{recursive:true});for(const [name,body]of Object.entries(files)){fs.mkdirSync(path.dirname(evidence+'/fixture/'+name),{recursive:true});fs.writeFileSync(evidence+'/fixture/'+name,body);}
const contractModule=await import(pathToFileURL(path.join(dir,'app/contract.mjs')).href);
const boundary=[];
const input={title:'Keep my edit',version:3};
const good={id:'article-1',title:input.title,version:4,committed:true,cacheInvalidated:true};
const responseCases=[
 ['malformed',200,'{','unknown'],['null',200,'null','unknown'],
 ['empty',200,'{}','unknown'],['missing-committed',200,JSON.stringify({...good,committed:undefined}),'unknown'],
 ['wrong-version',200,JSON.stringify({...good,version:5}),'unknown'],
 ['wrong-id',200,JSON.stringify({...good,id:'other'}),'unknown'],
 ['202',202,JSON.stringify(good),'unknown'],['500',500,'{}','unknown'],
 ['409',409,'{"error":"version_conflict"}','rejected'],
 ['valid',200,JSON.stringify(good),'confirmed'],
 ['cache-pending',200,JSON.stringify({...good,cacheInvalidated:false}),'confirmed']
];
for(const [name,status,body,want] of responseCases) {
 const result=await contractModule.submitEdit(async()=>new Response(body,{status}),input);
 assert.equal(result.phase,want);
 if(want!=='confirmed'){assert.equal(result.version,3);assert.equal(result.title,input.title);assert.equal(result.refresh,false);}
 if(name==='cache-pending')assert.equal(result.refresh,false);
 boundary.push({name,result,passed:true});
}
for(const name of ['fetch-rejected','body-rejected']){
 const result=await contractModule.submitEdit(async()=>{
  if(name==='fetch-rejected')throw Error('network');
  return {status:200,json:async()=>{throw Error('truncated body');}};
 },input);
 assert.equal(result.phase,'unknown');assert.equal(result.version,3);assert.equal(result.refresh,false);
 boundary.push({name,result,passed:true});
}
save('client-boundary.json',boundary);
for(const [name,body] of Object.entries(files)) {
 fs.mkdirSync(path.dirname(path.join(evidenceRoot,'fixture',name)),{recursive:true});
 fs.writeFileSync(path.join(evidenceRoot,'fixture',name),body);
}
const env={PATH:path.dirname(process.execPath)+':/usr/bin:/bin',HOME:dir,NEXT_TELEMETRY_DISABLED:'1',LAB_SOURCE:source};const cli=repo+'/node_modules/next/dist/bin/next';
async function run(args,log){return await new Promise((resolve,reject)=>{const fd=fs.openSync(evidence+'/'+log,'w');const p=spawn(process.execPath,[cli,...args],{cwd:dir,env,stdio:['ignore',fd,fd]});p.on('error',reject);p.on('exit',code=>{fs.closeSync(fd);resolve(code);});});}
let app;try{
 const built=await run(['build','--webpack'],'build.txt');if(built!==0)throw Error('build exit '+built);
 const probe=http.createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));const base='http://127.0.0.1:'+port;
 const fd=fs.openSync(evidence+'/server.txt','w');app=spawn(process.execPath,[cli,'start','-H','127.0.0.1','-p',String(port)],{cwd:dir,env,stdio:['ignore',fd,fd]});
 for(let n=0;n<100;n++){try{await fetch(base+'/missing');break;}catch{await new Promise(r=>setTimeout(r,100));}}
 const checks=[];async function req(name,url='/',options){const r=await fetch(base+url,options);const text=await r.text();save(name+'.txt','STATUS '+r.status+'\n'+[...r.headers].map(x=>x.join(': ')).join('\n')+'\n\n'+text);return {status:r.status,text};}
 function check(name,ok,details){checks.push({name,passed:!!ok,details});if(!ok)throw Error(name);}
 const first=await req('01-page');const firstReads=reads;const second=await req('02-page');
 check('shared_fetch_cache',firstReads===1&&reads===1,{firstReads,secondReads:reads});
 const tokens=t=>[...t.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/g)].map(x=>x[0]);
 const a=tokens(first.text),b=tokens(second.text);check('react_request_memo',a.length>=2&&new Set(a).size===1&&new Set(b).size===1&&a[0]!==b[0],{first:a,second:b});
 const flight=await req('03-flight','/?_rsc=fixture',{headers:{RSC:'1'}});check('dto_html_and_flight',[first,flight].every(x=>!x.text.includes('PRIVATE_NOTE_729')&&!x.text.includes('PRIVATE_DRAFT_816')&&!x.text.includes('internalNote')),{flightStatus:flight.status});
 const unsafe=await req('04-unsafe','/unsafe');check('unsafe_props_leak',unsafe.text.includes('PRIVATE_NOTE_729')&&unsafe.text.includes('PRIVATE_DRAFT_816'));
 const post=(actor,body)=>({method:'POST',headers:{'content-type':'application/json',...(actor?{'x-lab-session':actor}:{})},body:JSON.stringify(body)});
 check('401',(await req('05-no-auth','/api/article',post(null,{title:'x',version:1}))).status===401);
 check('403',(await req('06-wrong-author','/api/article',post('reader',{title:'x',version:1}))).status===403);
 check('400',(await req('07-bad-input','/api/article',post('author',{title:'',version:1}))).status===400);
 const saved=await req('08-save','/api/article',post('author',{title:'Updated title',version:1}));check('write_committed',saved.status===200&&JSON.parse(saved.text).version===2);
 const fresh=await req('09-fresh');check('fresh_after_invalidation',fresh.text.includes('Updated title')&&reads===2,{reads});
 check('409',(await req('10-conflict','/api/article',post('author',{title:'Stale edit',version:1}))).status===409);
 const saved3=await req('11-save','/api/article',post('author',{title:'Committed before refresh failure',version:2}));check('second_commit',saved3.status===200);
 fail=true;const broken=await req('12-refresh-failed');check('refresh_failure_after_commit',broken.status===500&&article.version===3,{status:broken.status,storedVersion:article.version});
 fail=false;const recovered=await req('13-refresh-recovered');check('read_recovered',recovered.status===200&&recovered.text.includes('Committed before refresh failure'));
 const writesBefore=writes;
 for(const [name,body] of [['null',null],['array',[]],['string','x'],['number',3],['empty',{}],['zero-version',{title:'x',version:0}],['extra',{title:'x',version:3,owner:'author'}]]) {
  const response=await req('boundary-input-'+name,'/api/article',post('author',body));
  check('invalid-input-'+name,response.status===400&&writes===writesBefore,{status:response.status,writes});
 }
 const malformed=await req('boundary-input-malformed','/api/article',{method:'POST',headers:{'content-type':'application/json','x-lab-session':'author'},body:'{'});
 check('invalid-input-malformed',malformed.status===400&&writes===writesBefore);
 for(const mode of ['null','malformed','wrong-id','wrong-version','uncommitted']){
  writeMode=mode;
  const version=article.version;
  const result=await req('boundary-upstream-'+mode,'/api/article',post('author',{title:'Receipt '+mode,version}));
  check('invalid-upstream-'+mode,result.status===502&&JSON.parse(result.text).error==='outcome_unknown'&&article.version===version+1,{status:result.status,committedVersion:article.version});
 }
 writeMode='normal';
 save('checks.json',{node:process.version,versions,workdir:dir,source,base,checks,sourceReads:reads,sourceWrites:writes,finalArticle:article});console.log(JSON.stringify(checks,null,2));
}finally{if(app){app.kill('SIGTERM');await new Promise(r=>app.once('exit',r));}service.closeAllConnections();await new Promise(r=>service.close(r));save('workdir.txt',dir);
 const build=path.join(dir,'.next'),link=path.join(dir,'node_modules');
 if(fs.existsSync(build)&&!fs.lstatSync(build).isSymbolicLink())fs.rmSync(build,{recursive:true});
 if(fs.existsSync(link)&&fs.lstatSync(link).isSymbolicLink())fs.unlinkSync(link);
 save('cleanup.json',{workdir:dir,removed:[build,link],preserved:'source, responses, evidence, project dependencies'});
}