构建系统与插件 SDK
前面都在看"功能";今天退后一步看"工程":6000+ TS 文件、几十个扩展、五个原生 App,怎么组织、构建、保证不写崩?pnpm workspace(一个厂区)+ tsdown 多入口打包(多条产品线)+ 插件 SDK 类型生成(给外部装配商的说明书)+ 质量门 + 分层测试。这是明天(Day 20)"部署上线"的前一步——先把产品造出来、验好货。
pnpm workspace
# pnpm-workspace.yaml 成员
. # 主包
ui # Web UI
packages/* # clawdbot/moltbot 兼容别名
extensions/* # 41 个渠道/能力扩展
onlyBuiltDependencies 白名单显式列出允许跑安装脚本的原生依赖(node-pty/sharp 等),防止随便哪个依赖在安装时执行代码(安全)。MsgContext 的一个字段(Day 15)。没有 workspace(各自独立发布):改主包 → 发
v1.2.4 → 到 telegram/slack/discord… 几十个扩展里逐个 npm i openclaw@1.2.4 → 全部重装 → 才用上。有 workspace:几十个扩展本来就通过软链接直接引用仓库里的主包源码 → 你一保存,它们立刻看到新字段,一次构建全部验证。→ "一个仓库共享零件"省掉了发布-升级-安装的整圈折腾。
tsdown 多入口
tsdown.config.ts 导出多入口配置数组:
- 主入口
src/index.ts、src/entry.ts、src/cli/daemon-cli.ts(:91-104)。 - 一组"懒运行时"渠道模块保持独立 dist 文件(
:106-118,whatsapp-login/discord/signal 等)。 - 插件 SDK 入口
pluginSdkEntrypoints(:43-88,约 60 个)。
platform:"node" + 生产环境。插件 SDK 入口
pluginSdkEntrypoints(约 60 个):core/compat + 每个渠道(telegram/discord/slack…)+ memory-core/voice-call 等,各自 src/plugin-sdk/<entry>.ts → dist/plugin-sdk/。
插件作者写:import ... from "openclaw/plugin-sdk/matrix"。
openclaw/plugin-sdk/* 导入类型和辅助函数。这一堆入口就是"给插件作者用的公开 API 表面"。vitest 别名把 openclaw/plugin-sdk/* 映射回 src/plugin-sdk/*.ts 供测试(免打包)。.d.ts 类型声明
build:plugin-sdk:dts = tsc -p tsconfig.plugin-sdk.dts.json(emitDeclarationOnly + noEmitOnError),只为那 ~60 个 SDK 入口生成 .d.ts 到 dist/plugin-sdk/。
.d.ts 是 TypeScript 的"类型说明书"——只有类型、没有实现代码。插件作者 import "openclaw/plugin-sdk/matrix" 时,编辑器靠这些 .d.ts 提供自动补全和类型检查("registerChannel 要传什么参数")。单独生成是因为:主代码用 tsdown 打包(快,但类型信息会丢),所以另用 tsc 专门为公开 API 生成精确的类型声明。noEmitOnError 保证类型有错就不出包——插件作者拿到的类型一定是对的。完整 build 链
# package.json 的 build(也是 prepack 一部分)串联:
canvas:a2ui:bundle # bundle A2UI(Canvas 资源,Day 15)
→ tsdown-build.mjs # 主打包
→ copy-plugin-sdk-root-alias.mjs
→ build:plugin-sdk:dts # 生成 SDK 类型(L04)
→ 一系列 tsx 脚本 # 写 dts、拷 A2UI/hook 元数据/HTML 模板、写 build-info
# build:docker = 去掉 A2UI bundle 的版本(Docker 里单独处理,容忍跨架构失败)
# UI 单独:pnpm ui:build
质量门 check
check 串联多道质量门:
format:check(oxfmt)+tsgo(类型检查)+lint(oxlint --type-aware)。- 自定义架构 lint:ingress-owner、channel-agnostic-boundaries、no-raw-channel-fetch、plugin 边界、webhook 鉴权顺序…
deadcode(knip/ts-prune)、dup:check(jscpd 查重复)、check:loc(单文件 ≤500 行)。- 原生:swiftformat/swiftlint +
check:host-env-policy:swift(保证 Swift 版环境策略与 TS 一致)。
check:host-env-policy:swift 确保 Swift 和 TS 两处的安全策略永远一致(Day 17 那份环境策略有 Swift 版)——把"两处要同步"这种易错点用检查兜住。单文件 ≤500 行强制拆分——这也是为什么它有 6000+ 文件。分层测试
多个 vitest.*.config.ts 分层(都 extend 基座 vitest.config.ts):
| 配置 | 范围 |
|---|---|
| unit | 纯单元,排除重集成目录(快、隔离) |
| channels | telegram/discord/web/browser/line |
| gateway | src/gateway/** |
| extensions | extensions/** |
| e2e | 端到端(fork 隔离,worker 少) |
| live | 打真实 LLM/服务(串行 maxWorkers:1) |
pool:"forks"(进程隔离,防 env/mock 跨文件泄漏)。还有 Docker e2e(scripts/e2e/*.sh)测真实容器场景。预提交钩子跑 detect-secrets/shellcheck/swiftlint 等。👶 小白:一套测试全跑完不就行了,为什么非要拆成 unit/channels/gateway/e2e/live 这么多层?
👨🏫 老师:因为它们"代价"差了好几个数量级。单元测试毫秒级、可几十个并发一起跑;而 live 测试要真的调用付费大模型 API,慢、要花钱、还可能触发限流,只能串行 maxWorkers:1 少量跑。分层后,你改一行代码可以只跑相关那层的快测(几秒出结果),不必每次都烧钱跑全套。基座还用 pool:"forks" 让每个测试文件在独立进程里跑,防止一个测试污染的环境变量/mock 泄漏到另一个——这也是"隔离"思想在测试上的体现。
今日小结 + 动手
🧠 今天你应该能回答
- pnpm workspace / monorepo 解决什么?onlyBuiltDependencies 防什么?
- tsdown 为什么多入口?懒运行时渠道为什么独立打包?
- 插件 SDK 入口 + .d.ts 各是给谁用的?
- 自定义架构 lint 是什么、好在哪?
- 为什么测试要分 unit/channels/gateway/e2e/live 这么多层?
✋ 动手
cd /Users/bitmart/work/codes/github/openclaw
cat pnpm-workspace.yaml
grep -n 'pluginSdkEntrypoints\|entry:' tsdown.config.ts | head
ls vitest.*.config.ts
grep -n '"build"\|"check"\|"test' package.json | head