ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LLM之Agent(九十七)|DeepSeek-Harness(七)构建体系:Host 与 Client 双面构建

LLM之Agent(九十七)|DeepSeek-Harness(七)构建体系:Host 与 Client 双面构建 整个仓库的 TypeScript 类型检查被硬性拆成两个永不合并的ts.Program——一个覆盖 Node 端的 Host,一个覆盖浏览器端的 Client。这不是工程洁癖式的拆着玩,而是因为 Host 和 Client 各自往同一个 CordisContext接口上declare moduleaugment 了不同的服务键(有些键名甚至相同、类型却不同),一旦这两组 augmentation 落进同一个编译单元,类型系统会把两侧的能力表合并成一张谁都看不清边界的假地图。本篇通过真实的tsconfig.*.json和tsdown.config.ts拆解这套双面构建的来龙去脉。学习目标理解tsconfig.json里files: [] 两个references意味着什么:根配置本身是无程序的纯引用清单,永不参与真实编译。搞清楚tsconfig.host.json与tsconfig.client.json分别覆盖哪些源文件/测试文件,以及它们各自references列表里为什么会有重叠的叶子包。理解 CordisContext类型合并机制,以及host/client 在同一个键上挂载不同的服务这件事为什么会让单一ts.Program失真。理解tsdown.config.ts里DSH_BUILD_FACE环境变量如何驱动两条完全不同的打包管线,以及packages/client/tsdown.client.ts里每个客户端包自带浏览器打包配置的机制。能够判断一个新写的包应该走 Host 构建、Client 构建,还是像packages/client/*里那样双面都要构建。背景与设计动机假设不做这个拆分,把packages/client/*里几十个 UI 插件包和packages/host/*、packages/core/*等 Node 端包全部塞进同一个tsc -b编译单元会发生什么?TypeScript 的模块声明合并(declaration merging)是全局生效的——只要某个.ts文件在这个编译单元里被加载过,它对declare module deepseek-ai/cordis里Context接口做的任何扩展,都会合并进整个程序里唯一的那份Context类型。也就是说,Node 端插件声明的ctx.fs(文件系统能力座)、ctx.sandbox(进程沙箱)会和浏览器端插件声明的ctx.theme(主题运行时)、ctx.modules(客户端模块系统)合并成同一张服务表。写浏览器端代码的人本该在编译期就被 TypeScript 挡下来的错误——比如误用一个只在 Node 里存在的ctx.fs——因为类型系统看得到这个键,反而会编译通过,直到打包阶段才因为找不到对应的运行时实现而崩溃,甚至更糟——悄悄地把一份不该在浏览器里出现的 Node 依赖打进产物。更麻烦的是同名键、不同类型的情况。根tsconfig.client.json的注释直接点出了这一点:// tsconfig.client.json { // Client-side typecheck aggregate: packages/client tests and top-level Client benchmarks. // Split from the host aggregate because both sides merge cordis Context // under the same keys (sessions, loader) with different services; shared // leaves (session/llm/tools/...) build once and are referenced by // both programs through each client packages own references. extends: ./tsconfig.base.client.json, ... }sessions、loader这两个键在 Host 侧和 Client 侧都存在,但背后的服务类型完全不同。比如loader这个键,vendored 的 Cordis Loader 插件在 Node 端是这样声明的:// vendor/loader/src/index.ts interface Context { loader: Loader }这里的Loader是 Node 端管理插件生命周期的类。如果 Client 侧某个包又在同一个合并单元里给ctx.loader(或类似的ctx.modules)声明了另一套浏览器端的类型,单一ts.Program就会拿到两个互相冲突或互相覆盖的类型定义——merge 会静默吃掉其中一份,而不是报错提醒你这其实是两个不同的东西。这正是本篇标题里双面构建存在的根本原因:必须让 Host 和 Client 的 Context 合并各自独立发生,谁也看不见谁的那一份。核心机制详解根tsconfig.json:一个无程序的引用清单// tsconfig.json当前版本注释里对 examples/ 的提法已经去掉 // 因为 examples 这个 workspace 成员在第 01 篇写作后被整体移除了 { // Solution file: the whole-repo graph for tsc -b tsconfig.json and the // tsserver entry. extends carries the base paths for get-tsconfig // consumers — tsx running scripts/ (no nearer tsconfig) // resolves workspace imports through this file. files: [] keeps it // program-less, so the host/client cordis Context merges never meet. // NEVER add include/files entries, and NEVER flatten this solution into a // single ts.Program (scripts seed tsconfig.host.json or tsconfig.client.json). extends: ./tsconfig.base.json, files: [], references: [ { path: ./tsconfig.host.json }, { path: ./tsconfig.client.json } ] }关键是files: []。TypeScript 的 Project References 模式下,一个 solution 文件如果自己不声明任何files/include,就永远不会被实例化成一个真正的ts.Program——它只是给tsc -b(批量构建)和 tsserver(编辑器智能提示)一个这里有两个独立子工程的路标。注释里写得很直白:永远不要往这个文件加include/files,永远不要把这个 solution 压平成单一ts.Program——这两条禁令直接对应前面讲的合并风险。同时它extends了tsconfig.base.json,这样像tsx这种没有更贴近的 tsconfig 可用的场景(运行scripts/下的脚本),依然能通过这份基础路径映射解析到工作区内的包。tsconfig.host.json:Node 端聚合工程// tsconfig.host.json { // Host aggregate (one of the two check units; see tsconfig.json). packages/client // type-checks in tsconfig.client.json: the two sides merge cordis Context under // the same keys, one program cannot see both. extends: ./tsconfig.base.json, compilerOptions: { noEmit: true, rewriteRelativeImportExtensions: false }, include: [ apps/web/tests/scaffold.ts, ... apps/cli/tests/**/*.ts, packages/*/*/tests/**/*.ts, scripts/**/*.ts, ... ], exclude: [ packages/client/*/src/**, packages/*/*/tests/**/*.client.ts, packages/*/*/tests/**/*.client.tsx, packages/*/*/tests/**/*.client.spec.ts, packages/*/*/tests/**/*.client.spec.tsx, ... ], references: [ { path: ./vendor/cosmokit }, { path: ./vendor/cordis }, ... { path: ./packages/core/agent-loop }, { path: ./apps/cli } ] }几个要点:include里几乎不出现packages/client/*/src——exclude显式把它排除掉了(packages/client/*/src/**)。Host 工程只覆盖 Node 端源码、脚本、以及测试文件里不带.client.后缀的部分。*.client.ts/*.host.spec.ts是两套互斥的文件名约定:一个 client 包如果同时有 Node 半区和浏览器半区,测试文件用文件名后缀标注自己属于哪一半——*.client.*归 Client 聚合,*.host.spec.ts归 Host 聚合。两边互相exclude对方的后缀,所以公共的测试 glob(packages/*/*/tests/**/*.ts)不需要为每个文件单独配置。references列出上百个具体叶子包的路径——这是 TypeScript Project References 的硬性要求:引用必须显式列出,不支持 glob 通配。这也是为什么tsconfig.base.json里那份路径映射表(下面会看到)需要用paths通配符做另一层补充——references管的是编译顺序和增量缓存边界,paths管的是裸模块名怎么解析到源码。tsconfig.client.json与tsconfig.base.client.json:浏览器端聚合工程// tsconfig.base.client.json { // Client-side compiler shape shared by tsconfig.client.json and every // packages/client/* package: browser library API, React JSX, no ambient // node types (packages that need them override locally). extends: ./tsconfig.base.json, compilerOptions: { jsx: react-jsx, lib: [ES2024, DOM, DOM.Iterable, ESNext.Disposable], typeRoots: [./scripts/types, ./node_modules/types], types: [client-build-environment] } }tsconfig.client.json继承的正是这份tsconfig.base.client.json,而不是tsconfig.host.json继承的tsconfig.base.json——差异集中在几处,每一处都在划清这是浏览器代码的边界:lib换成[ES2024, DOM, DOM.Iterable, ESNext.Disposable](能用document、window、显式资源释放语法,不能用 Node 的 ambient 类型);jsx: react-jsx(浏览器端才需要 JSX 转换);types不再是空数组,而是[client-build-environment],配合typeRoots: [./scripts/types, ./node_modules/types]指向仓库自己维护的一个环境声明包(scripts/types/client-build-environment)——它只声明了打包器会在构建期替换掉的少数几个process.env.*字段(NODE_ENV和形如DSH_CLIENT_*的自定义变量),依然做到不引入types/node这类完整的 Node 环境类型,只是把完全不声明process换成了精确声明打包期真的会替换的那几个字段,让浏览器代码里出现的少量process.env.xxx判断也能过类型检查。tsconfig.client.json本身:// tsconfig.client.json { extends: ./tsconfig.base.client.json, compilerOptions: { noEmit: true, rewriteRelativeImportExtensions: false, // Tests execute under vitest on node (e2e files spawn processes); browser // purity of package src is each packages own tsconfig plus // scripts/client-bundle-purity.spec.ts. types: [node] }, include: [ packages/client/*/src/css-modules.d.ts, packages/client/*/tests/**/*.ts, packages/client/*/tests/**/*.tsx, packages/*/*/tests/**/*.client.spec.ts, ... ], exclude: [ packages/client/*/tests/**/*.host.spec.ts ], references: [ { path: ./packages/host/webserver }, { path: ./packages/compaction/compaction }, { path: ./packages/client/ui-slots }, ... { path: ./apps/web } ] }注意这里types反而被设成了[node]——注释解释得很清楚:这份聚合工程编译的是测试文件,测试跑在 vitest 之上、跑在 Node 进程里(e2e 测试还会 spawn 子进程),所以测试代码需要 Node 类型;而包源码本身是否保持浏览器纯净是另一件事,由每个客户端包自己的 tsconfig(继承tsconfig.base.client.json,types: [])加上scripts/client-bundle-purity.spec.ts这个专门的构建期校验来保证。这是测试聚合工程的类型环境和被测源码的类型环境故意错开的一个细节。references里能看到共享叶子包同时出现在 Host 和 Client 的引用列表里,比如packages/compaction/compaction。这些包(session、llm、tools等)本身不依赖 Cordis 的Context类型合并——它们只导出纯类型或不含跨插件运行时身份的东西,所以可以只构建一次,被两个程序分别引用,而不违反两侧合并互不可见的约束。两侧真的会挂载同名键,不同类型:一个可验证的例子Host 侧,packages/core/session/src/index.ts给Context挂了sessions键:// packages/core/session/src/index.ts declare module deepseek-ai/cordis { interface Context { sessions: SessionStore } interface Events { /** ... */ } }loader键则是 vendored Cordis Loader 插件在 Node 端声明的:// vendor/loader/src/index.ts interface Context { loader: Loader }而 Client 侧的等价能力,走的是完全不同的类型和键名——比如主题运行时:// packages/client/ui-theme/src/client/index.ts declare module deepseek-ai/cordis { interface Context { theme: ThemeRuntime } interface Events { theme/change(snapshot: ThemeSnapshot): void } }sessions、loader这两个键名在浏览器端语境下另有所指(tsconfig.client.json注释明确点名了这两个键),背后类型与 Node 端并不相同。如果两侧的declare module语句被同一个ts.Program加载,TypeScript 会把二者做接口合并——合并结果既不是 Host 期望的类型,也不是 Client 期望的类型,而是两者字段的并集(遇到同名同结构字段会合并、遇到冲突签名可能直接报错或产生令人费解的联合类型)。拆成两个ts.Program之后,Host 编译时只加载 Host 侧那组declare module,Client 编译时只加载 Client 侧那组,两份Context类型永远互不相见,这才是谁也看不见谁的那一份在类型系统层面的真实含义。构建管线:DSH_BUILD_FACE驱动的两条 tsdown 通路类型检查分两半只是故事的一半,产物构建同样分两条流水线。根package.json里:build:lib: pnpm run build:lib:host pnpm run build:lib:client, build:lib:host: node --max-old-space-size4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host, build:lib:client: tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client,脚本管理器从 npm 换成了 pnpm,这和第 01 篇讲的pnpm-workspace.yaml是一致的;Host 侧的tsc -b现在还多了一层显式的node --max-old-space-size4096包装——这是仓库体量继续增长后,Host 聚合工程的类型检查图变得足够大,需要手动把 V8 堆上限调高才能稳定跑完的直接证据,侧面印证了第 01 篇叶子包从 219 涨到 307这件事对工程侧的真实代价。每一面先用对应的tsc -b把 TypeScript 降级成 JavaScript(降级到各自lib/types目录),再用 tsdown 把 JS 打包成最终发布产物。tsdown.config.ts就是这条分流的入口:// tsdown.config.ts import { defineConfig } from tsdown import { typertPlugin } from ./packages/typert/generator/lib/types/tsdown-plugin.js function isBuildFaceClient(value: unknown): boolean { if (value undefined || value host) return false if (value client) return true throw new Error(tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}) } /** * The ordinary workspace build consumes JavaScript emitted by the Host * TypeScript project and runs Typert. The Client pass selects packages that * declare a browser bundle and lets their package-local configs emit both * their Node loader entry and browser artifact. */ export default defineConfig(({ env }) { const client isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: client ? [vendor/*, packages/*/*, apps/cli] : [vendor/*, packages/*/*, apps/cli, apps/desktop, apps/desktop-host], entry: client ? : [lib/types/{index,invariant,startup}.js], outDir: lib, format: [esm], platform: node, target: es2024, fixedExtension: false, dts: false, clean: false, plugins: client ? [] : [typertPlugin({ mode: workspace, faces: [host] })], } })这里的分流策略很微妙:Host Pass(DSH_BUILD_FACEhost,或不传)对每一个workspace 包统一打包lib/types/{index,invariant,startup}.js三个标准入口,并顺手跑一次 Typert 产物生成器。Client Pass(DSH_BUILD_FACEclient)则把entry清空()——也就是说对绝大多数包什么都不做,真正需要产出浏览器 bundle 的包必须自带一份 package 级tsdown.config.ts,用自己的配置覆盖掉这份根配置,自行决定要不要在 Client Pass 里再emit 一份 Node 半区和一份浏览器半区。值得注意的是,workspace字段现在按 Host/Client 分成了两份不同的包列表:Client Pass 依然只覆盖apps/cli,但 Host Pass 多出了apps/desktop和apps/desktop-host——课程第 01 篇写作之后新增的 Electron 桌面应用及其宿主进程,只需要 Node 端的标准入口打包,完全不需要经过 Client Pass 的浏览器 bundle 流程,这也印证了它们本质上是桌面壳套了一层 Electron,内部驱动逻辑仍是 Node 进程。这份包级覆盖的公共实现就是packages/client/tsdown.client.ts,它导出的clientBundle()帮助函数被每个 UI 插件包的tsdown.config.ts调用:// packages/client/tsdown.client.ts export function clientBundle( id: string, libEntry: readonly string[], options: ClientBundleOptions {}, ): BuildFaceConfig { const lib clientLibraryConfig(id, libEntry, options.lib) return ({ env }) { const face buildFace(env?.DSH_BUILD_FACE) const clientEntry face undefined ? src/client/index.ts : lib/types/client/index.js const client clientConfig(id, clientEntry, options.clientBanner) const node [lib, ...(options.companions ?? [])] if (face host) return options.hostPhase true ? node : [SKIP_WORKSPACE_BUILD] if (face client) { return options.hostPhase true ? [client] : [...node, client] } return [...node, client] } }默认情况下(options.hostPhase不设置),一个客户端插件包在 Host Pass 里完全跳过(SKIP_WORKSPACE_BUILD{ entry: }),把 Node 半区和浏览器半区都留给 Client Pass 一起产出——这样浏览器 bundle 打包时,Rolldown 能直接看到刚生成的lib/types/client/index.js,不需要额外的跨阶段协调。clientConfig()里还藏着浏览器打包必须处理的一整套细节。这部分代码在课程写作之后经历了一次实质性重构——最核心的变化是 externals 机制从一份写死的全局CLIENT_EXTERNALS数组变成了共享基线 每个包自己声明的组合:// packages/client/tsdown.client.ts当前版本节选,做了删减聚焦于 externals/purity 这条主线; // 实际实现还包含更完整的 CSS Modules/内联样式虚拟模块、异步 chunk 拆分等逻辑 function clientExternals(id: string): ReadonlySetstring { const cached clientExternalCache.get(id) if (cached ! undefined) return cached const externals new Set([ ...PLATFORM_MODULES, ...PRELOADED_CLIENT_EXTERNALS, ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}), ]) clientExternalCache.set(id, externals) return externals } function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) string | undefined): UserConfig { const isRequested (specifier: string): boolean clientExternals(id).has(specifier) return { name: ${id}/client, entry: { client: entry }, outDir: lib, format: cjs, platform: browser, dts: false, sourcemap: true, clean: false, deps: { neverBundle: isRequested, alwaysBundle: (specifier: string) !isRequested(specifier), }, define: { ...clientBuildEnvironmentDefines(process.env), process.env.NODE_ENV: JSON.stringify(process.env.NODE_ENV ?? production), import.meta.env.MODE: JSON.stringify(process.env.NODE_ENV ?? production), import.meta.env: JSON.stringify({ MODE: process.env.NODE_ENV ?? production }), }, plugins: [{ name: dsh-client-bundle-purity, resolveId(source: string) { if (!source.startsWith(deepseek-ai/)) return null if (isRequested(source)) return null // requested module-table row: external wins if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null throw new Error( client bundle purity: ${source} is not in the default client externals or ${id}s dsh.client.external, an inline-safe wire layer, or a generated /remote contribution — cross-plugin value imports are forbidden; declare a non-default module request or collaborate through cordis services (type-only imports are erased and never reach this gate), ) }, } /* ...CSS Modules 内联插件、异步 chunk require 插件等... */], outputOptions: { entryFileNames: client.js, banner: window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) {, footer: return module.exports; } });, intro: var module { exports: {} }; var exports module.exports;, }, } }external/noExternal这两个 tsdown 的通用选项,现在被deps.neverBundle/deps.alwaysBundle这一对更细粒度的谓词函数取代——语义没有变(还是外部化 vs 内联这个二分),但判断依据从是否在一份写死的数组里变成了是否在clientExternals(id)算出的这个包专属的许可集合里。这个集合由三部分拼起来:仓库统一的平台模块基线(PLATFORM_MODULES)、预置的常用外部依赖(PRELOADED_CLIENT_EXTERNALS),以及这个包自己package.json里dsh.client.external字段声明的额外请求项(requestedExternals()从workspaceManifest(id).dsh?.client里读出来)——也就是说,原来哪些模块能被外部化是一份仓库级别的全局清单,现在下放成了每个客户端包在自己的package.json里显式声明自己还需要哪些额外的外部依赖,dsh-client-bundle-purity插件的报错信息里也把这一点讲得很直白(...or ${id}s dsh.client.external...)。dsh-client-bundle-purity插件本身承担的角色没有变——它依然是构建期对双面构建约束的又一层强制:在resolveId阶段拦截每一个deepseek-ai/*的导入,只允许平台/包自己声明的外部模块、vendored 库、显式标记为无运行时身份的线路层的包这三类情况通过,任何其他跨插件的值导入都会在构建期直接抛错。常见问题/易踩坑误以为packages/client/*/src会被tsc -b tsconfig.host.json检查到——不会,tsconfig.host.json的exclude显式排除了它;如果在 Host 侧看到某个客户端包的类型错误没被报出来,先确认没有搞错编译入口。新增一个客户端叶子包却忘了在tsconfig.client.json的references里补上路径——TypeScript Project References 不支持通配符,漏加引用会导致 tsserver 报找不到模块或增量构建顺序错乱,即便tsconfig.base.json的paths映射表里已经写对了。给一个客户端包写tsdown.config.ts时忘记覆盖根配置的entry: [lib/types/{index,invariant,startup}.js]——根配置对 Client Pass 默认清空 entry,包级配置如果没有正确调用clientBundle()/clientOnly()之类的帮助函数,很容易导致 Client Pass 什么都没产出。小结Host/Client 双面构建的根源是 CordisContext类型的全局声明合并特性:两侧会在同一批服务键上(有些键名甚至完全相同)挂载完全不同的类型,一旦这些declare module落进同一个ts.Program,类型系统看到的就是一张失真的能力表。仓库用tsconfig.json(files: []的纯引用清单)分裂出tsconfig.host.json和tsconfig.client.json两个独立编译单元,再用tsdown.config.ts的DSH_BUILD_FACE环境变量驱动两条对应的打包管线——Host Pass 统一产出所有包的标准入口,Client Pass 把浏览器 bundle 的产出权交给每个客户端包自己的tsdown.config.ts(多数基于packages/client/tsdown.client.ts提供的clientBundle()),并在打包期用 purity 插件把跨插件只能走 Cordis 服务这条架构约束变成一道硬性的构建门禁。
返回列表