从源码构建 dsh:构建流水线与 Host/Client 双面
专栏:DeepSeek Harness · 第 3 / 14 篇上篇我们用 npx 把 dsh 跑了起来。这篇进入源码:从 git clone 到 pnpm dsh web 能跑,中间发生了什么。
最短路径
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install # 包管理器锁定 pnpm@11.7.0,Node 要求 ^22.19.0 || >=24.0.0
pnpm run build # 全量构建,产物落在各包自己的 lib/
pnpm dsh web # 源码形态启动,直接消费构建产物
一个容易困惑的点:pnpm dsh 走的是 node --import tsx/esm apps/cli/src/bin.ts——TypeScript 入口直跑,不经过构建。但它要求构建产物已经存在:启动过程会加载 Typert 反射产物和前端 dist,缺了会在 profile 启动时报错并提示 pnpm run build。所以「源码直跑」不等于「不需要构建」。
构建流水线:四步
pnpm run build = tsx scripts/build.ts,主体很薄:解析 --profile(或环境变量)算出客户端公共构建环境(内联根包版本、7 位 commit、dirty 标记)→ 依次跑 build:lib 和 build:web → 把环境与产物摘要写进 gitignore 的记录文件。核心在 build:lib,它是固定顺序的四步:
tsc -b tsconfig.host.json # ① Host 聚合 tsc:全部 TS 源发射为 lib/types/**/*.js
tsdown --env.DSH_BUILD_FACE host # ② Host 打包:消费 ① 的产物,并运行 Typert 类型反射
tsc -b tsconfig.client.json # ③ Client 聚合 tsc
tsdown --env.DSH_BUILD_FACE client # ④ Client 打包:各 Client 包产出 Node loader 入口 + browser bundle
build:web 再用 vite 把 apps/web 打到 apps/web/dist。构建入口脚本本身值得一提:
// scripts/build.ts:32-52
function main(): void {
const { values } = parseArgs({
options: { profile: { type: 'string' } },
allowPositionals: false,
})
const root = resolve(import.meta.dirname, '..')
const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env)
const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR]
const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile)
const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment)
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
runScript('build:lib', buildEnvironment)
runScript('build:web', buildEnvironment)
const record = writeClientBuildRecord(root, clientEnvironment)
console.log(
`build: recorded ${String(record.artifacts.fileCount)} client artifact(s) with ${String(Object.keys(record.environment).length)} public value(s)`,
)
}
版本号和 commit 在这里被内联进客户端构建环境——浏览器端显示的版本信息来自构建时的注入,而不是运行时探测。
为什么要两个 tsconfig「面」
这是这个仓库最特别的工程决定。dsh 的前后端(宿主进程与浏览器)共享大量类型:都通过 Cordis 的 declare module 往同一批 Context 键上做接口合并。问题在于:一个 ts.Program 如果同时看到两侧合并进来的同名服务声明,就会类型冲突。
dsh 的解法是一个 solution、两个互斥的聚合 program:
tsconfig.base.json:共享 compilerOptions + 全部@deepseek-ai/*源码paths别名(同时是 vitest 的解析门面,因此永远不许加 include/files)。tsconfig.host.json/tsconfig.client.json:两个聚合入口,互斥地纳入各自源码与测试(*.host.spec.ts归 Host,*.client.spec.ts归 Client)。- 只有 6 个「拆分包」需要两侧各一份 leaf tsconfig(如
api/gateway、client/connection)。
tsdown 的配置能看出两个面共享同一份 workspace 列表,只靠环境变量切换:
// tsdown.config.ts:16-30
export default defineConfig(({ env }) => {
const client = isBuildFaceClient(env?.DSH_BUILD_FACE)
return {
workspace: ['vendor/*', 'packages/*/*', 'apps/cli'],
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 面的 entry 是 lib/types/*.js——它只消费前置 tsc 发射的 JS,不直接碰 TS 源码。这也是「四步必须按序」的原因:① 的产物是 ② 的输入。
产物地图与验证
全部产物落在每个包自己的 lib/(不在根目录 dist),几个关键位置:
产物地图与验证
全部产物落在每个包自己的 lib/(不在根目录 dist),几个关键位置:
| 位置 | 内容 |
|---|---|
apps/cli/lib/bin.js | npm 安装形态的 CLI 入口(npx 跑的就是它) |
apps/web/dist | 前端静态资源 |
.dsh-build/client-build-environment.json | 构建记录(版本/commit/产物摘要),release 打包会拒绝缺失或陈旧的记录 |
packages/*/lib/types/** | Host tsc 发射物(tsdown 的输入) |
日常循环建议:
pnpm run typecheck # = 完整 Host lib 构建 + tsc -b client
pnpm run lint # oxlint
pnpm run test # vitest(还有 e2e/snapshot/web 等多套 config)
pnpm run clean # 白名单清理:lib/、.dsh-build、*.tsbuildinfo 等
改了源码之后 pnpm dsh web 不会自动重建——它消费的是上次构建的产物。改完记得重跑对应步骤;全量 pnpm run build 在这个体量的仓库里并不快。
从构建产物回看启动
现在可以把「源码形态启动」画成时序图——重点看它在哪里消费构建产物:
可以说:构建负责把「源码 + 配置树」变成「可挂载的插件集合」,启动负责把插件集合变成运行中的进程。
下一篇开始补地基:这一切的运行时底座——Cordis 插件框架。