DSH / Atlas
2026-08-08implementedprocess

Ordered Build for API Remotes Generated Contracts

API Remotes 生成约定的有序构建

Typert must generate `/remote` declarations and runtime contributions from the Host's `@Remote` methods before the Client's `api-remotes/src/client/index.ts` can typecheck and bundle those contributions. If the root build hands both the Host and Client Project Reference graphs to tsc together, the Client compiles before the generated artifacts exist. Adding a separate contracts preprocessing step would instead compil

English

Problem

Typert must generate /remote declarations and runtime contributions from the Host's @Remote methods before the Client's api-remotes/src/client/index.ts can typecheck and bundle those contributions. If the root build hands both the Host and Client Project Reference graphs to tsc together, the Client compiles before the generated artifacts exist. Adding a separate contracts preprocessing step would instead compile the generator again outside the normal Host graph and let stale artifacts hide incorrect dependencies.

This ordering dependency must not change the repository's ordinary package rule. A normal package belongs to exactly one TypeScript face: Host packages are registered in tsconfig.host.json, and Client packages in tsconfig.client.json. A Client plugin having both a Node loader entry and a browser entry describes its bundled artifact shapes, not a reason to split its TypeScript project.

Decision

The root build completes Host tsc and Host tsdown first, with Host tsdown running Typert and generating the Remote Client contract. It then completes Client tsc, Client tsdown, and the Web build:

tsc -b tsconfig.host.json
tsdown --env.DSH_BUILD_FACE host
tsc -b tsconfig.client.json
tsdown --env.DSH_BUILD_FACE client
Vite Web build

build:lib:host owns the first two steps, build:lib:client owns the middle two, and build:web runs last. typecheck must also run the complete Host lib phase first because Client tsc requires declarations generated by Host tsdown; it does not need Client tsdown or the Web build.

Each tsc phase is the sole TypeScript compiler path and emits JavaScript, declarations, and incremental state to lib/types. Tsdown reads only that JavaScript and produces published bundles; it neither reads source nor emits declarations.

The sole package exception

api/remotes is the only package with both Host and Client composite projects. The Host project contains the Agent/Session lookup policy, Host plugin entry, and invariant; the Client project contains only src/client/index.ts, which must wait for the generated contract:

packages/api/remotes/
├─ tsconfig.json
├─ tsconfig.host.json
├─ tsconfig.client.json
└─ src/
   ├─ index.ts
   ├─ agent-lookup.ts
   ├─ invariant.ts
   └─ client/
      └─ index.ts

The package-root tsconfig.json is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and host/apiproxy reference api/remotes/tsconfig.host.json, while the root Client aggregate and client/ui-goal reference api/remotes/tsconfig.client.json. ui-goal itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only tsconfig.json remain valid from either face.

The two projects use disjoint files and separate .tsbuildinfo files, so they can share lib/types without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects.

This exception follows from the real generated-contract ordering and is not a template available to ordinary packages. New packages remain restricted to one aggregate; adding another exception requires changing this decision and proving another generated dependency that cannot be eliminated.

Typert and tsdown

Host tsdown enables typertPlugin({ mode: 'workspace', faces: ['host'] }) in the normal root config. The generator uses only tsconfig.host.json as its program seed and produces both typert.host.* and the typert.remote-client.* projection of Host contracts; Client tsdown neither starts Typert nor analyzes the Client aggregate.

The Typert analyzer distinguishes compiler faces from runtime faces. Direct Project References in the aggregate determine which compiler face analyzes a project; only a split project explicitly referenced through tsconfig.host.json or tsconfig.client.json is restricted to that corresponding face. Runtime models follow package subpath contributions instead, so an ordinary single-project dshClient package may contribute both Host and Client runtime models. Consequently, Host analysis of api-remotes does not also register its Client entry, while an ordinary dual-entry package does not lose its Host model.

Both the Host and Client tsdown passes receive the same complete workspace of vendor/*, packages/*/*, and apps/cli. The root config does not scan lib/types/client/index.js, maintain a package classification table, or use a tsdown filter; package-local configs return entries for the current phase according to DSH_BUILD_FACE.

An ordinary Client plugin returns an empty config during the Host pass and produces both its Node loader entry and browser bundle during the Client pass. The clientBundle(..., { hostPhase: true }) used by api-remotes is the only phase exception: the Host pass produces its Host entry, and the Client pass produces only its browser bundle. Package-local tsdown without DSH_BUILD_FACE still returns that package's normal entries together for local single-package development.

Alternatives considered

Keep a separate contracts preprocessing step. This would compile the generator again outside the normal Host Project Reference graph and let residual generated artifacts hide the Client entering the Host graph too early.

Run the root tsc -b tsconfig.json once before tsdown. Client tsc would run before Host tsdown and could not obtain /remote declarations from a clean worktree.

Split every package containing src/client/index.ts. Separate Node and browser entries are the normal Client plugin bundling convention and do not create a compilation ordering dependency; splitting them universally would only increase the maintenance cost of references and incremental state.

Scan Client compilation artifacts or maintain two workspace lists. Artifact scanning would make package participation depend on residual files, while hand-maintained lists and package-name filters would drift as directories change. A complete workspace with package-local face selection already provides deterministic behavior.

Run Typert again during the Client pass. Remote Client is a projection of the Host contract and has no independent Client reflection source; a second Typert program would only duplicate work and increase the risk of mixing both sides' declarations into one analysis.

Consequences

A clean build is the authoritative check of ordering correctness: with no existing /remote artifacts, Host tsc must succeed first, Host tsdown must generate the contract, and then Client tsc, Client tsdown, and the Web build must succeed. No phase may write artifacts into src.

The tsc-first ownership established by the TypeScript build config note remains unchanged, but this note replaces its command shape of one whole-graph tsc pass followed by bundling with ordered phases. The ordinary-package single-aggregate rule established by the two-aggregate solution note also remains unchanged; this note creates one explicit exception for api/remotes.

An independent Client build is no longer a self-contained entry on a clean worktree; repository commands, CI, and release flows must run the Host lib phase first. Developers of ordinary packages do not need to understand or copy this exception and continue to choose one aggregate according to the package's runtime environment.

中文

问题

Host 的 @Remote 方法需要先由 Typert 生成 /remote 声明和运行时贡献,Client 的 api-remotes/src/client/index.ts 才能通过类型检查并打包这些贡献。若根构建先把 Host 与 Client 两张 Project Reference 图一起交给 tsc,Client 会在生成产物存在之前编译;若增加独立 contracts 预处理,又会让 generator 脱离正常 Host 图重复编译,并允许陈旧产物掩盖错误依赖。

该顺序依赖不能改变仓库的普通 package 规则。正常 package 只属于一个 TypeScript face:Host package 登记在 tsconfig.host.json,Client package 登记在 tsconfig.client.json。一个 Client plugin 同时具有 Node loader 入口与 browser 入口,只是打包产物形态,不是拆分 TypeScript project 的理由。

决策

根构建先完成 Host tsc 和 Host tsdown,由 Host tsdown 运行 Typert 并生成 Remote Client 约定;随后完成 Client tsc、Client tsdown 和 Web 构建:

tsc -b tsconfig.host.json
tsdown --env.DSH_BUILD_FACE host
tsc -b tsconfig.client.json
tsdown --env.DSH_BUILD_FACE client
Vite Web build

build:lib:host 负责前两步,build:lib:client 负责中间两步,build:web 最后运行。typecheck 也必须先执行完整 Host lib 阶段,因为 Client tsc 需要 Host tsdown 生成的声明;它不需要运行 Client tsdown 或 Web build。

每个 tsc 阶段都是唯一的 TypeScript 编译器路径,负责向 lib/types 发射 JavaScript、声明和增量状态。tsdown 只读取这些 JavaScript 并生成发布 bundle,不读取源码,也不生成声明。

唯一的 package 特例

api/remotes 是唯一同时拥有 Host 与 Client composite project 的 package。Host project 包含 Agent/Session lookup 策略、Host 插件入口和 invariant;Client project 只包含需要等待生成约定的 src/client/index.ts

packages/api/remotes/
├─ tsconfig.json
├─ tsconfig.host.json
├─ tsconfig.client.json
└─ src/
   ├─ index.ts
   ├─ agent-lookup.ts
   ├─ invariant.ts
   └─ client/
      └─ index.ts

包根 tsconfig.json 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 host/apiproxy 引用 api/remotes/tsconfig.host.json;根 Client aggregate 与 client/ui-goal 引用 api/remotes/tsconfig.client.jsonui-goal 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 tsconfig.json 的目标仍可由任一 face 引用。

两个 project 使用互不重叠的 files 和不同的 .tsbuildinfo,因此可以共享 lib/types 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。

这个例外由生成约定的真实先后关系决定,不是可供普通 package 选择的模板。新增 package 仍只能登记进一个 aggregate;只有修改本决策并证明存在另一条不可消除的生成依赖,才能增加例外。

Typert 与 tsdown

Host tsdown 在普通根配置中启用 typertPlugin({ mode: 'workspace', faces: ['host'] })。generator 只以 tsconfig.host.json 为 program 种子,生成 typert.host.* 以及 Host 约定投影出的 typert.remote-client.*;Client tsdown 不启动 Typert,也不分析 Client aggregate。

TypeScript compiler face 与 Typert 运行时产物 face 是两层概念。普通 dshClient package 即使只有一个 compiler project,也可以按公开 subpath 同时贡献 Host 与 Client 运行时模型;aggregate 显式引用 tsconfig.host.jsontsconfig.client.json 时,analyzer 才把该 project 限定到对应 face。因此 api-remotes 的 Host 分析不会顺带注册其 Client 入口,普通双入口 package 的 Host 模型也不会丢失。

Host 与 Client 两次 tsdown 都接收 vendor/*packages/*/*apps/cli 这组完整 workspace。根配置不扫描 lib/types/client/index.js,不维护 package 分类表,也不使用 tsdown filter;包内配置根据 DSH_BUILD_FACE 返回本阶段入口。

普通 Client plugin 在 Host pass 返回空配置,在 Client pass 同时生成 Node loader 入口与 browser bundle。api-remotesclientBundle(..., { hostPhase: true }) 是唯一阶段例外:Host pass 生成其 Host 入口,Client pass 只生成 browser bundle。未指定 DSH_BUILD_FACE 的 package-local tsdown 仍同时返回该 package 的正常入口,供本地单包开发使用。

考虑过的替代方案

保留独立 contracts 预处理。 这会在正常 Host Project Reference 图之外额外编译 generator,并让残留生成物掩盖 Client 过早进入 Host 图的问题。

一次执行根 tsc -b tsconfig.json 后再运行 tsdown。 Client tsc 在 Host tsdown 之前发生,无法从干净工作树获得 /remote 声明。

拆分所有包含 src/client/index.ts 的 package。 Node 与 browser 双入口是普通 Client plugin 的打包约定,不形成编译顺序依赖;普遍拆分只会增加 references 和增量状态的维护成本。

扫描 Client 编译产物或维护两份 workspace 清单。 产物扫描会让 package 是否参与构建取决于残留文件,手工清单和 package 名过滤则会随目录调整产生漂移。完整 workspace 加包内 face 选择已经提供确定行为。

在 Client pass 再运行 Typert。 Remote Client 是 Host 约定的投影,没有独立 Client 反射源;第二个 Typert program 只会重复工作并增加两侧声明混入同一分析的风险。

后果

干净构建成为顺序正确性的权威验证:没有任何既存 /remote 产物时,Host tsc 必须先成功,Host tsdown 必须生成约定,随后 Client tsc、Client tsdown 与 Web build 必须成功。任何阶段都不得把产物写进 src

TypeScript 构建配置 Note确定的 tsc-first 职责保持不变,但其单次全图 tsc 后再打包的命令形态由本文的有序阶段取代。双 aggregate solution Note确定的普通 package 单 aggregate 规则保持不变,本文只为 api/remotes 建立一个显式例外。

Client 的独立构建不再是干净工作树上的自足入口;仓库命令、CI 和发布流程必须先运行 Host lib 阶段。普通 package 的开发者无需理解或复制该例外,仍按所属运行环境选择一个 aggregate。