From 17a8658551f499452d7c0a56c6b983ae6c0b3da3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 14 Sep 2026 19:10:26 +0000 Subject: [PATCH 1/4] fix(runtime): declare zod as a ^4.5.0 peer so consumers share one copy --- .changeset/runtime-zod-peer.md | 6 ++++++ .../create-agent-bundle/templates/cli-tool/package_json | 2 +- .../templates/mcp-server/package_json | 2 +- packages/rsc-runtime/package.json | 9 +++++---- pnpm-lock.yaml | 6 +++--- website/docs/en/guide/start/installation.mdx | 5 +++++ website/docs/zh/guide/start/installation.mdx | 5 +++++ 7 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 .changeset/runtime-zod-peer.md diff --git a/.changeset/runtime-zod-peer.md b/.changeset/runtime-zod-peer.md new file mode 100644 index 000000000..e0c558be6 --- /dev/null +++ b/.changeset/runtime-zod-peer.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"create-agent-bundle": patch +--- + +Declare `zod` as a `^4.5.0` peer dependency of `@agent-bundle/runtime` instead of an exact `4.5.4` dependency, so a project and the runtime share one installed Zod copy. Zod 4 brands schema types by minor version; with the nested copy, a project on `zod@4.6.x` failed to typecheck any schema it passed to `defineState` or a route (`Type '6' is not assignable to type '5'`). The `cli-tool` and `mcp-server` scaffold templates pin `zod@4.5.4` to satisfy the peer (#PR) diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json index d708ca7ba..ddd436aa8 100644 --- a/packages/create-agent-bundle/templates/cli-tool/package_json +++ b/packages/create-agent-bundle/templates/cli-tool/package_json @@ -31,6 +31,6 @@ "@agent-bundle/runtime": "workspace:*", "react": "19.2.8", "react-dom": "19.2.8", - "zod": "4.4.3" + "zod": "4.5.4" } } diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json index 0250ac120..241d68808 100644 --- a/packages/create-agent-bundle/templates/mcp-server/package_json +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -34,6 +34,6 @@ "@agent-bundle/runtime": "workspace:*", "react": "19.2.8", "react-dom": "19.2.8", - "zod": "4.4.3" + "zod": "4.5.4" } } diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 1c30719d2..79cd2b9e4 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -82,7 +82,8 @@ "peerDependencies": { "@rspack/core": "^2.2.0-0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "zod": "^4.5.0" }, "peerDependenciesMeta": { "@rspack/core": { @@ -94,8 +95,7 @@ "effect": "4.0.0-rc.112", "flare-redact": "1.6.1", "react-server-dom-rspack": "0.1.0", - "rsc-markdown-stream": "workspace:^", - "zod": "4.5.4" + "rsc-markdown-stream": "workspace:^" }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0", @@ -107,6 +107,7 @@ "micromark": "4.0.2", "micromark-extension-gfm": "3.0.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "zod": "4.5.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 101c999dc..8d95502f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,9 +408,6 @@ importers: rsc-markdown-stream: specifier: workspace:^ version: link:../rsc-markdown-stream - zod: - specifier: 4.5.4 - version: 4.5.4 devDependencies: '@modelcontextprotocol/client': specifier: 2.0.0 @@ -442,6 +439,9 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) + zod: + specifier: 4.5.4 + version: 4.5.4 packages/workbench: dependencies: diff --git a/website/docs/en/guide/start/installation.mdx b/website/docs/en/guide/start/installation.mdx index 6ce452f56..a3153385c 100644 --- a/website/docs/en/guide/start/installation.mdx +++ b/website/docs/en/guide/start/installation.mdx @@ -73,6 +73,11 @@ pair without preinstalling React or bypassing peer checks. Routed scaffolds stil known-good React/React DOM pair intentionally: generated projects start reproducibly, and their committed lockfiles preserve that pair. +`@agent-bundle/runtime` declares `zod` as a `^4.5.0` peer rather than a dependency for the same +reason: route and state definitions hand Zod schemas across the runtime's API, and Zod 4 brands +schema types by minor version, so the project and the runtime must share one installed copy. A +project that bumps its own `zod` keeps typechecking; a nested second copy would not. + ## Once npm releases exist The commands below are the **future** installation path. They do not work yet, because no npm diff --git a/website/docs/zh/guide/start/installation.mdx b/website/docs/zh/guide/start/installation.mdx index 3e6195bee..c4800b859 100644 --- a/website/docs/zh/guide/start/installation.mdx +++ b/website/docs/zh/guide/start/installation.mdx @@ -65,6 +65,11 @@ React 和 React DOM 声明同一个范围,因此全新的 npm 安装可以选 React,也无需绕过 peer 检查。路由式脚手架仍会有意固定一组已验证的 React/React DOM 版本: 生成的项目由此保持可复现,提交后的 lockfile 会继续保留这组版本。 +出于同样的原因,`@agent-bundle/runtime` 将 `zod` 声明为 `^4.5.0` peer 依赖而非普通依赖:路由和 +状态定义会把 Zod schema 传过 runtime 的 API,而 Zod 4 会按 minor 版本给 schema 类型打标记, +所以项目与 runtime 必须共用同一份安装。项目自行升级 `zod` 后仍能通过类型检查;若嵌套第二份 +副本则不行。 + ## 等到 npm 正式发布之后 下面这些命令是**未来**的安装路径。它们现在还不可用,因为尚未发布任何 npm 版本: From 5db7e5ad866b61ccf0efd1132944b64eaa9573d0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 14 Sep 2026 19:10:36 +0000 Subject: [PATCH 2/4] chore: reference PR in changeset --- .changeset/runtime-zod-peer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/runtime-zod-peer.md b/.changeset/runtime-zod-peer.md index e0c558be6..5680727d3 100644 --- a/.changeset/runtime-zod-peer.md +++ b/.changeset/runtime-zod-peer.md @@ -3,4 +3,4 @@ "create-agent-bundle": patch --- -Declare `zod` as a `^4.5.0` peer dependency of `@agent-bundle/runtime` instead of an exact `4.5.4` dependency, so a project and the runtime share one installed Zod copy. Zod 4 brands schema types by minor version; with the nested copy, a project on `zod@4.6.x` failed to typecheck any schema it passed to `defineState` or a route (`Type '6' is not assignable to type '5'`). The `cli-tool` and `mcp-server` scaffold templates pin `zod@4.5.4` to satisfy the peer (#PR) +Declare `zod` as a `^4.5.0` peer dependency of `@agent-bundle/runtime` instead of an exact `4.5.4` dependency, so a project and the runtime share one installed Zod copy. Zod 4 brands schema types by minor version; with the nested copy, a project on `zod@4.6.x` failed to typecheck any schema it passed to `defineState` or a route (`Type '6' is not assignable to type '5'`). The `cli-tool` and `mcp-server` scaffold templates pin `zod@4.5.4` to satisfy the peer (#793) From 5f9353699dd9ea4e83011d6cf8f5c82fcb79e33d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 14 Sep 2026 19:30:51 +0000 Subject: [PATCH 3/4] fix(runtime): floor the zod peer at 4.5.4, move packed fixtures off 4.4.3, mark the runtime bump minor --- .changeset/runtime-zod-peer.md | 4 ++-- packages/agent-bundle/tests/dev-workbench-packaging.test.ts | 2 +- .../agent-bundle/tests/packed-readonly-state-root.test.ts | 2 +- packages/agent-bundle/tests/packed-stdio-projection.test.ts | 2 +- packages/agent-bundle/tests/packed-web-command.test.ts | 2 +- packages/rsc-runtime/package.json | 2 +- website/docs/en/guide/start/installation.mdx | 2 +- website/docs/zh/guide/start/installation.mdx | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/runtime-zod-peer.md b/.changeset/runtime-zod-peer.md index 5680727d3..8523257dd 100644 --- a/.changeset/runtime-zod-peer.md +++ b/.changeset/runtime-zod-peer.md @@ -1,6 +1,6 @@ --- -"@agent-bundle/runtime": patch +"@agent-bundle/runtime": minor "create-agent-bundle": patch --- -Declare `zod` as a `^4.5.0` peer dependency of `@agent-bundle/runtime` instead of an exact `4.5.4` dependency, so a project and the runtime share one installed Zod copy. Zod 4 brands schema types by minor version; with the nested copy, a project on `zod@4.6.x` failed to typecheck any schema it passed to `defineState` or a route (`Type '6' is not assignable to type '5'`). The `cli-tool` and `mcp-server` scaffold templates pin `zod@4.5.4` to satisfy the peer (#793) +Declare `zod` as a `^4.5.4` peer dependency of `@agent-bundle/runtime` instead of an exact `4.5.4` dependency, so a project and the runtime share one installed Zod copy. Zod 4 brands schema types by minor version; with the nested copy, a project on `zod@4.6.x` failed to typecheck any schema it passed to `defineState` or a route (`Type '6' is not assignable to type '5'`). Projects pinned below `zod@4.5.4` must upgrade: the floor excludes the 4.5.0–4.5.3 default-factory regression the runtime already required 4.5.4 to avoid. The `cli-tool` and `mcp-server` scaffold templates pin `zod@4.5.4` to satisfy the peer (#793) diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 376027bf6..f5e41f7e6 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -131,7 +131,7 @@ it('packages both react-server render children and renders a route invocation fr markdownStream.tarball, 'react@19.2.8', 'react-dom@19.2.8', - 'zod@4.4.3', + 'zod@4.5.4', ], { cwd: consumer, env: installedEnvironment() }); await mkdir(join(project, 'src', 'mcp', 'status', 'tools'), { recursive: true }); await Promise.all([ diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 6f065fbef..e0c68c449 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -93,7 +93,7 @@ it('serves a state-writing tool from a read-only installed artifact without writ markdownStream.tarball, 'react@19.2.8', 'react-dom@19.2.8', - 'zod@4.4.3', + 'zod@4.5.4', ], { cwd: project, env: installedEnvironment() }); const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); await execFile(cli, ['build', '--root', project, '--output', artifact], { diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 4b6e5893a..68d92b9b7 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -92,7 +92,7 @@ it.each([ markdownStream.tarball, 'react@19.2.8', 'react-dom@19.2.8', - 'zod@4.4.3', + 'zod@4.5.4', ], { cwd: project, env: installedEnvironment() }); // The fixture selects `claude`, the only target whose capabilities cover diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index 468c34723..64c8facf6 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -104,7 +104,7 @@ beforeAll(async () => { markdownStream.tarball, 'react@19.2.8', 'react-dom@19.2.8', - 'zod@4.4.3', + 'zod@4.5.4', ], { cwd: project, env: installedEnvironment() }); const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); await execFile(cli, ['build', '--root', project, '--output', artifact], { diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 79cd2b9e4..728ac58ad 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -83,7 +83,7 @@ "@rspack/core": "^2.2.0-0", "react": "^19.2.0", "react-dom": "^19.2.0", - "zod": "^4.5.0" + "zod": "^4.5.4" }, "peerDependenciesMeta": { "@rspack/core": { diff --git a/website/docs/en/guide/start/installation.mdx b/website/docs/en/guide/start/installation.mdx index a3153385c..c79d827a3 100644 --- a/website/docs/en/guide/start/installation.mdx +++ b/website/docs/en/guide/start/installation.mdx @@ -73,7 +73,7 @@ pair without preinstalling React or bypassing peer checks. Routed scaffolds stil known-good React/React DOM pair intentionally: generated projects start reproducibly, and their committed lockfiles preserve that pair. -`@agent-bundle/runtime` declares `zod` as a `^4.5.0` peer rather than a dependency for the same +`@agent-bundle/runtime` declares `zod` as a `^4.5.4` peer rather than a dependency for the same reason: route and state definitions hand Zod schemas across the runtime's API, and Zod 4 brands schema types by minor version, so the project and the runtime must share one installed copy. A project that bumps its own `zod` keeps typechecking; a nested second copy would not. diff --git a/website/docs/zh/guide/start/installation.mdx b/website/docs/zh/guide/start/installation.mdx index c4800b859..95172004d 100644 --- a/website/docs/zh/guide/start/installation.mdx +++ b/website/docs/zh/guide/start/installation.mdx @@ -65,7 +65,7 @@ React 和 React DOM 声明同一个范围,因此全新的 npm 安装可以选 React,也无需绕过 peer 检查。路由式脚手架仍会有意固定一组已验证的 React/React DOM 版本: 生成的项目由此保持可复现,提交后的 lockfile 会继续保留这组版本。 -出于同样的原因,`@agent-bundle/runtime` 将 `zod` 声明为 `^4.5.0` peer 依赖而非普通依赖:路由和 +出于同样的原因,`@agent-bundle/runtime` 将 `zod` 声明为 `^4.5.4` peer 依赖而非普通依赖:路由和 状态定义会把 Zod schema 传过 runtime 的 API,而 Zod 4 会按 minor 版本给 schema 类型打标记, 所以项目与 runtime 必须共用同一份安装。项目自行升级 `zod` 后仍能通过类型检查;若嵌套第二份 副本则不行。 From d5b4493e3e35fdfd83378a29826460d3061fcdb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 19:37:26 +0000 Subject: [PATCH 4/4] docs(runtime): spell out the zod 4.4.3 peer upgrade and pack the 4.6.x defineState proof Existing projects pinned below zod@4.5.4 must upgrade before the required peer will install. The installation pages now distinguish incompatible Zod 4 minor brands from a same-minor duplicate, and the packed suite typechecks a real defineState() call on zod@4.6.5 against the peer versus a nested 4.5.4 dependency. Co-authored-by: Zack Jackson --- .../tests/manifest-contract.test.ts | 7 + .../rsc-runtime/tests/packed-zod-peer.test.ts | 223 ++++++++++++++++++ rstest.integration-tests.ts | 1 + website/docs/en/guide/start/installation.mdx | 8 +- website/docs/zh/guide/start/installation.mdx | 9 +- 5 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 packages/rsc-runtime/tests/packed-zod-peer.test.ts diff --git a/packages/rsc-runtime/tests/manifest-contract.test.ts b/packages/rsc-runtime/tests/manifest-contract.test.ts index 39e342f9d..186f9025d 100644 --- a/packages/rsc-runtime/tests/manifest-contract.test.ts +++ b/packages/rsc-runtime/tests/manifest-contract.test.ts @@ -60,6 +60,13 @@ describe('@agent-bundle/runtime manifest', () => { expect(runtimeManifest.dependencies['rsc-markdown-stream']).toBe('workspace:^'); }); + it('declares zod as a required caret peer at the proven 4.5.4 floor', () => { + expect(peers.zod).toBe('^4.5.4'); + expect(runtimeManifest.peerDependenciesMeta).not.toHaveProperty('zod'); + expect(runtimeManifest.dependencies).not.toHaveProperty('zod'); + expect(runtimeManifest.devDependencies.zod).toBe('4.5.4'); + }); + it('documents the React contract the manifest declares', async () => { const readme = await readFile(new URL('../README.md', import.meta.url), 'utf8'); expect(readme).toContain(`React/React DOM \`${peers.react}\``); diff --git a/packages/rsc-runtime/tests/packed-zod-peer.test.ts b/packages/rsc-runtime/tests/packed-zod-peer.test.ts new file mode 100644 index 000000000..50a520bcb --- /dev/null +++ b/packages/rsc-runtime/tests/packed-zod-peer.test.ts @@ -0,0 +1,223 @@ +import { execFile as executeFile } from 'node:child_process'; +import type { Dirent } from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, relative } from 'node:path'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from '@rstest/core'; + +import runtimeManifest from '../package.json' with { type: 'json' }; +import { + cachedNpmInstallArguments, + installedEnvironment, + packOutputFromJson, + sharedPackedTarball, +} from '../../agent-bundle/tests/support/shared-pack.ts'; + +const execFile = promisify(executeFile); +const workspaceRoot = process.cwd(); +const tscBin = join(workspaceRoot, 'node_modules', '.bin', 'tsc'); + +const consumerZod = '4.6.5'; +const nestedZod = '4.5.4'; +const defineStateSource = [ + "import { defineState } from '@agent-bundle/runtime/state';", + "import { z } from 'zod';", + '', + 'export default defineState({', + " id: 'zod-peer/counter',", + " lifetime: 'process',", + ' initial: { count: 0 },', + ' schema: z.object({ count: z.number() }),', + ' events: { tick: z.object({}) },', + ' reduce: (state) => state,', + '});', + '', +].join('\n'); + +const defineStateTsconfig = `${JSON.stringify({ + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + noEmit: true, + pretty: false, + skipLibCheck: false, + strict: true, + target: 'es2022', + types: [], + }, + files: ['state.ts'], +}, null, 2)}\n`; + +interface InstalledRuntimeManifest { + readonly dependencies: Readonly>; + readonly name: string; + readonly peerDependencies: Readonly>; +} + +interface ZodCopy { + readonly path: string; + readonly version: string; +} + +const execFileOutput = (error: unknown): { readonly stderr: string; readonly stdout: string } => { + if (error === null || typeof error !== 'object') { + return { stderr: String(error), stdout: '' }; + } + return { + stderr: 'stderr' in error && typeof error.stderr === 'string' ? error.stderr : '', + stdout: 'stdout' in error && typeof error.stdout === 'string' ? error.stdout : '', + }; +}; + +const installedZodCopies = async (nodeModules: string): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(nodeModules, { recursive: true, withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const copies = await Promise.all(entries.flatMap((entry) => { + if (!entry.isFile() || entry.name !== 'package.json' || basename(entry.parentPath) !== 'zod') { + return []; + } + return [(async (): Promise => { + const manifest = JSON.parse(await readFile(join(entry.parentPath, 'package.json'), 'utf8')) as { + readonly name?: string; + readonly version: string; + }; + if (manifest.name !== 'zod') return undefined; + return { path: relative(nodeModules, entry.parentPath), version: manifest.version }; + })()]; + })); + return copies + .filter((copy): copy is ZodCopy => copy !== undefined) + .sort((left, right) => left.path.localeCompare(right.path)); +}; + +const writeDefineStateConsumer = async (consumer: string): Promise => { + await Promise.all([ + writeFile(join(consumer, 'package.json'), '{"name":"runtime-zod-peer-consumer","private":true,"type":"module"}\n'), + writeFile(join(consumer, 'state.ts'), defineStateSource), + writeFile(join(consumer, 'tsconfig.json'), defineStateTsconfig), + ]); +}; + +const installRuntimeConsumer = async ( + consumer: string, + runtimeTarball: string, + markdownStreamTarball: string, +): Promise => { + await writeDefineStateConsumer(consumer); + await execFile('npm', [ + 'install', + ...cachedNpmInstallArguments, + runtimeTarball, + markdownStreamTarball, + `react@${runtimeManifest.devDependencies.react}`, + `react-dom@${runtimeManifest.devDependencies['react-dom']}`, + `zod@${consumerZod}`, + ], { cwd: consumer, env: installedEnvironment() }); +}; + +const typecheckDefineState = async ( + consumer: string, +): Promise<{ readonly ok: true } | { readonly ok: false; readonly output: string }> => { + try { + const result = await execFile(tscBin, ['-p', 'tsconfig.json'], { + cwd: consumer, + env: installedEnvironment(), + }); + if (result.stdout === '' && result.stderr === '') return { ok: true }; + return { ok: false, output: `${result.stdout}\n${result.stderr}` }; + } catch (error) { + const { stderr, stdout } = execFileOutput(error); + return { ok: false, output: `${stdout}\n${stderr}` }; + } +}; + +const packNestedZodRuntime = async (runtimeTarball: string, destination: string): Promise => { + await mkdir(destination, { recursive: true }); + await execFile('tar', ['-xzf', runtimeTarball, '-C', destination]); + const root = join(destination, 'package'); + const manifestPath = join(root, 'package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as InstalledRuntimeManifest; + const peerDependencies = Object.fromEntries( + Object.entries(manifest.peerDependencies).filter(([name]) => name !== 'zod'), + ); + const rewritten = { + ...manifest, + dependencies: { ...manifest.dependencies, zod: nestedZod }, + peerDependencies, + }; + await writeFile(manifestPath, `${JSON.stringify(rewritten, null, 2)}\n`); + const { stdout } = await execFile('npm', ['pack', '--json', '--pack-destination', destination], { + cwd: root, + env: installedEnvironment(), + }); + return join(destination, packOutputFromJson(stdout, rewritten.name).filename); +}; + +/** + * #793: `@agent-bundle/runtime` used to pin `zod@4.5.4` as a regular + * dependency. Zod 4 brands `ZodType` by `_zod.version.minor`, so a consumer + * on `zod@4.6.x` could not pass any schema to `defineState`. The packed + * peer install must share one physical copy and typecheck; restoring the + * exact nested dependency must reproduce the brand error. + */ +describe.sequential('packed @agent-bundle/runtime zod peer', () => { + it('typechecks a consumer zod@4.6.5 defineState schema against one physical peer install', async () => { + const [runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const consumer = await mkdtemp(join(tmpdir(), 'runtime-zod-peer-')); + try { + await installRuntimeConsumer(consumer, runtime.tarball, markdownStream.tarball); + + const installed = join(consumer, 'node_modules', '@agent-bundle', 'runtime'); + const manifest = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')) as InstalledRuntimeManifest; + expect(manifest.peerDependencies.zod).toBe(runtimeManifest.peerDependencies.zod); + expect(manifest.dependencies).not.toHaveProperty('zod'); + expect(await installedZodCopies(join(consumer, 'node_modules'))).toEqual([ + { path: 'zod', version: consumerZod }, + ]); + + const typecheck = await typecheckDefineState(consumer); + if (!typecheck.ok) { + throw new Error(`Packed defineState typecheck failed.\n${typecheck.output}`); + } + } finally { + await rm(consumer, { force: true, recursive: true }); + } + }, 180_000); + + it('reproduces the cross-minor brand error when the packed runtime depends on zod@4.5.4 exactly', async () => { + const [runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const workspace = await mkdtemp(join(tmpdir(), 'runtime-zod-nested-')); + const consumer = join(workspace, 'consumer'); + try { + await mkdir(consumer); + const nestedTarball = await packNestedZodRuntime(runtime.tarball, join(workspace, 'nested-runtime')); + await installRuntimeConsumer(consumer, nestedTarball, markdownStream.tarball); + + expect(await installedZodCopies(join(consumer, 'node_modules'))).toEqual([ + { path: '@agent-bundle/runtime/node_modules/zod', version: nestedZod }, + { path: 'zod', version: consumerZod }, + ]); + + const typecheck = await typecheckDefineState(consumer); + expect(typecheck.ok).toBe(false); + if (typecheck.ok) throw new Error('expected the nested 4.5.4 copy to fail defineState typecheck'); + expect(typecheck.output).toContain("Type '6' is not assignable to type '5'"); + expect(typecheck.output).toContain('_zod.version.minor'); + } finally { + await rm(workspace, { force: true, recursive: true }); + } + }, 180_000); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 1bd5590f3..b963a2daf 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -176,6 +176,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts', 'packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts', 'packages/rsc-runtime/tests/packed-entry-identity.test.ts', + 'packages/rsc-runtime/tests/packed-zod-peer.test.ts', 'packages/workbench/tests/packed-release.e2e.test.ts', ]; diff --git a/website/docs/en/guide/start/installation.mdx b/website/docs/en/guide/start/installation.mdx index c79d827a3..f96c79c27 100644 --- a/website/docs/en/guide/start/installation.mdx +++ b/website/docs/en/guide/start/installation.mdx @@ -75,8 +75,12 @@ committed lockfiles preserve that pair. `@agent-bundle/runtime` declares `zod` as a `^4.5.4` peer rather than a dependency for the same reason: route and state definitions hand Zod schemas across the runtime's API, and Zod 4 brands -schema types by minor version, so the project and the runtime must share one installed copy. A -project that bumps its own `zod` keeps typechecking; a nested second copy would not. +schema types by minor version. A nested `zod@4.5.x` therefore rejects a consumer schema from +`zod@4.6.x` (`Type '6' is not assignable to type '5'`). Two installations of the same minor +still typecheck; the peer is so the project and the runtime resolve one copy and stay on +compatible minors. Existing projects still pinned to `zod@4.4.3`, or any version below +`4.5.4`, must upgrade that direct dependency before installing this runtime — npm otherwise +rejects the required peer with `ERESOLVE`. ## Once npm releases exist diff --git a/website/docs/zh/guide/start/installation.mdx b/website/docs/zh/guide/start/installation.mdx index 95172004d..93f35abd6 100644 --- a/website/docs/zh/guide/start/installation.mdx +++ b/website/docs/zh/guide/start/installation.mdx @@ -66,9 +66,12 @@ React,也无需绕过 peer 检查。路由式脚手架仍会有意固定一组 生成的项目由此保持可复现,提交后的 lockfile 会继续保留这组版本。 出于同样的原因,`@agent-bundle/runtime` 将 `zod` 声明为 `^4.5.4` peer 依赖而非普通依赖:路由和 -状态定义会把 Zod schema 传过 runtime 的 API,而 Zod 4 会按 minor 版本给 schema 类型打标记, -所以项目与 runtime 必须共用同一份安装。项目自行升级 `zod` 后仍能通过类型检查;若嵌套第二份 -副本则不行。 +状态定义会把 Zod schema 传过 runtime 的 API,而 Zod 4 会按 minor 版本给 schema 类型打标记。 +因此嵌套的 `zod@4.5.x` 无法接受来自 `zod@4.6.x` 的消费方 schema(`Type '6' is not assignable +to type '5'`)。同一 minor 的两份安装仍能通过类型检查;peer 的作用是让项目与 runtime 解析到 +同一份副本,并停留在兼容的 minor 上。仍将直接依赖固定在 `zod@4.4.3`、或任何低于 `4.5.4` +的版本的现有项目,必须先升级该依赖再安装此 runtime,否则 npm 会以 `ERESOLVE` 拒绝所需的 +peer。 ## 等到 npm 正式发布之后