Day 19 / 共 20 天 · 第 4 周 运行时/安全/部署

构建系统与插件 SDK

前面都在看"功能";今天退后一步看"工程":6000+ TS 文件、几十个扩展、五个原生 App,怎么组织、构建、保证不写崩?pnpm workspace(一个厂区)+ tsdown 多入口打包(多条产品线)+ 插件 SDK 类型生成(给外部装配商的说明书)+ 质量门 + 分层测试。这是明天(Day 20)"部署上线"的前一步——先把产品造出来、验好货。

📍 你在整门课的位置(第 4 周 · 运行时/安全/部署)
D15 归一化 D16 沙箱运行时 D17 安全纵深 D18 原生App D19 构建SDK D20 部署收官
🤔 痛点:6000 个文件、几十个包,不管会怎样? 想象几十个小包各自是独立仓库:改一行公共代码,得先发布新版本,再到几十个依赖它的包里逐个升级、安装——一天啥都别干了。而且没人拦着你写出"渠道代码直接 fetch 外网""插件偷访问核心内部"这种坏味道,几个月后代码就烂成一锅粥。今天讲的这套工程,就是让"大"不等于"乱"。
💡 用一个类比兜住整天(「一座工厂」世界观) 今天全程一个画面:一座大工厂pnpm workspace = 一个厂区里几十个车间共用同一个零件仓库,A 车间改了零件,B 车间立刻用上新的,不用"重新进货"。tsdown 多入口 = 厂里开好几条产品线,常用的主产品一条线,不常用的(whatsapp/signal 登录)单独一条,谁下单才开哪条。插件 SDK + .d.ts = 发给外部配件商的"接线图和说明书"(公开 API 表面 + 精确类型),照着接就不会插错。check 质量门 = 出厂前的质检关卡,连"零件必须走抽象接口、配件不许私自碰主板内部"这种装配规矩都用架构 lint 强制检查。分层测试 = 不同等级的抽检:快检(单元)天天做、全检(e2e/live)慢且贵、少而精。记住"一座管理有序的工厂",今天全通。
L01

pnpm workspace

# pnpm-workspace.yaml 成员
.              # 主包
ui             # Web UI
packages/*     # clawdbot/moltbot 兼容别名
extensions/*   # 41 个渠道/能力扩展
workspace(工作区)是什么? 一个大项目常拆成很多小包(主程序、UI、几十个扩展)。pnpm workspace 让这些小包住在一个仓库里、共享依赖、互相能直接引用——改一个包,依赖它的包立即用上新版,不用发布再安装。这就是"monorepo(单体仓库)"的做法:一个仓库管全部,统一构建、统一测试。onlyBuiltDependencies 白名单显式列出允许跑安装脚本的原生依赖(node-pty/sharp 等),防止随便哪个依赖在安装时执行代码(安全)。
📝 举个例子:改一行公共代码,几十个包立刻生效 你修了主包里 MsgContext 的一个字段(Day 15)。
没有 workspace(各自独立发布):改主包 → 发 v1.2.4 → 到 telegram/slack/discord… 几十个扩展里逐个 npm i openclaw@1.2.4 → 全部重装 → 才用上。
有 workspace:几十个扩展本来就通过软链接直接引用仓库里的主包源码 → 你一保存,它们立刻看到新字段,一次构建全部验证。→ "一个仓库共享零件"省掉了发布-升级-安装的整圈折腾。
L02

tsdown 多入口

tsdown.config.ts 导出多入口配置数组:

  • 主入口 src/index.tssrc/entry.tssrc/cli/daemon-cli.ts:91-104)。
  • 一组"懒运行时"渠道模块保持独立 dist 文件(:106-118,whatsapp-login/discord/signal 等)。
  • 插件 SDK 入口 pluginSdkEntrypoints:43-88,约 60 个)。
读法:为什么"懒运行时"渠道要独立打包?因为不是每次都用到所有渠道——独立成文件,用到哪个才加载哪个(呼应 Day 15 的轻量 loader)。tsdown 是基于 rolldown 的快速打包器,platform:"node" + 生产环境。
L03

插件 SDK 入口

pluginSdkEntrypoints(约 60 个):core/compat + 每个渠道(telegram/discord/slack…)+ memory-core/voice-call 等,各自 src/plugin-sdk/<entry>.tsdist/plugin-sdk/

插件作者写:import ... from "openclaw/plugin-sdk/matrix"

读法:Day 12 讲扩展能注入能力,靠的就是从 openclaw/plugin-sdk/* 导入类型和辅助函数。这一堆入口就是"给插件作者用的公开 API 表面"。vitest 别名把 openclaw/plugin-sdk/* 映射回 src/plugin-sdk/*.ts 供测试(免打包)。
L04

.d.ts 类型声明

build:plugin-sdk:dts = tsc -p tsconfig.plugin-sdk.dts.jsonemitDeclarationOnly + noEmitOnError),只为那 ~60 个 SDK 入口生成 .d.tsdist/plugin-sdk/

.d.ts 是什么?为什么单独生成? .d.ts 是 TypeScript 的"类型说明书"——只有类型、没有实现代码。插件作者 import "openclaw/plugin-sdk/matrix" 时,编辑器靠这些 .d.ts 提供自动补全和类型检查("registerChannel 要传什么参数")。单独生成是因为:主代码用 tsdown 打包(快,但类型信息会丢),所以另用 tsc 专门为公开 API 生成精确的类型声明。noEmitOnError 保证类型有错就不出包——插件作者拿到的类型一定是对的。
L05

完整 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
读法:build 不是一条命令,而是一条流水线:打包主代码 → 生成插件类型 → 拷贝各种资源(Canvas/hook/模板)→ 写构建信息。Docker 构建用不同变体(Day 20)是为了容忍 ARM/x86 跨架构差异。
L06

质量门 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 一致)。
"自定义架构 lint" 很值得学 普通 lint 查代码风格。OpenClaw 还写了架构级 lint——用规则强制"渠道代码不能直接 fetch(必须走抽象)""插件不能越过边界访问核心内部""webhook 必须先鉴权再处理"。这些规则把架构约定变成"违反就报错"的自动检查,防止代码腐化。还有 check:host-env-policy:swift 确保 Swift 和 TS 两处的安全策略永远一致(Day 17 那份环境策略有 Swift 版)——把"两处要同步"这种易错点用检查兜住。单文件 ≤500 行强制拆分——这也是为什么它有 6000+ 文件。
L07

分层测试

多个 vitest.*.config.ts 分层(都 extend 基座 vitest.config.ts):

配置范围
unit纯单元,排除重集成目录(快、隔离)
channelstelegram/discord/web/browser/line
gatewaysrc/gateway/**
extensionsextensions/**
e2e端到端(fork 隔离,worker 少)
live打真实 LLM/服务(串行 maxWorkers:1)
读法:分层是因为不同测试"代价"不同:单元测试快、可大并发;live 测试打真实 API,慢且要串行(省钱防限流)。基座用 pool:"forks"(进程隔离,防 env/mock 跨文件泄漏)。还有 Docker e2e(scripts/e2e/*.sh)测真实容器场景。预提交钩子跑 detect-secrets/shellcheck/swiftlint 等。
分层抽检金字塔:越往上越慢越贵,越少 live e2e(fork 隔离) channels / gateway / extensions unit(快·可大并发) 串行·打真实API 按模块分组 最多·最快 慢/贵/少 → 快/廉/多
图注:不是所有测试都一样贵——单元测试天天大批量跑,live 测试打真实 LLM 只能串行少量跑(省钱防限流)。

👶 小白:一套测试全跑完不就行了,为什么非要拆成 unit/channels/gateway/e2e/live 这么多层?

👨‍🏫 老师:因为它们"代价"差了好几个数量级。单元测试毫秒级、可几十个并发一起跑;而 live 测试要真的调用付费大模型 API,慢、要花钱、还可能触发限流,只能串行 maxWorkers:1 少量跑。分层后,你改一行代码可以只跑相关那层的快测(几秒出结果),不必每次都烧钱跑全套。基座还用 pool:"forks" 让每个测试文件在独立进程里跑,防止一个测试污染的环境变量/mock 泄漏到另一个——这也是"隔离"思想在测试上的体现。

L08

今日小结 + 动手

🧠 今天你应该能回答

  • 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
明天预告 · Day 20(结业)部署与收官——多阶段 Dockerfile、docker-compose、fly.io/render、rootless podman 部署,外加全课回顾 + 七大框架终极对比(eino/OpenHands/crewAI/AutoGPT/SuperAGI/LangGraph/OpenClaw)。
← Day 18 原生应用 Day 20 · 部署与收官 →