dsh-WeCom-notify 开发经验(Development Notes)
August 15, 2026 · View on GitHub
本文沉淀 v0.3.0(设置面板 + 多 webhook key 同时通知)开发过程中的关键知识, 尤其是「DSH 插件 client 设置面板」的正确姿势与踩坑记录。 面向后续维护者与想给 dsh 写「设置页插件」的开发者。
1. 插件 client 设置面板的正确姿势(本次最大坑)
dsh 的「设置」页(设置 → 各 section)由 settings.section 插槽承载:
kind: 'list'、scope: 'root'(契约见 @deepseek-ai/dsh-client-ui-settings 的
contract/slots.d.ts)。
注册契约:component 是 React 组件,且必须是 register() 的第二参数:
ctx.slots.inject('settings.section', () =>
ctx.slots.register(
{
name: 'settings.section',
id: 'wecom-notify', // section 导航 key
order: 40, // 导航排序
label: () => '企业微信通知', // 显示名(可函数跟随 locale)
},
WecomNotifyPanel, // ⚠️ React 组件作第二参数!
),
)
❌ 错误写法(曾导致面板空白):照抄 dsh-super-injector 的
ctx.slots.register({ ..., component: () => ({ render() {...} }) }) // 单参数!
两个问题叠加:
SlotRegistry.register(rawOptions, component)的组件在第二参数;单参调用时 component 恒为undefined;- 即便传入,
() => ({ render() })返回普通对象,也不是合法 ReactNode。
web-react renderer 渲染时执行 jsx(Comp, props),失败被 SlotErrorBoundary 吞掉 →
导航项「企业微信通知」正常显示(label 在 options 里),内容区空白。诊断要点:
导航 label 出现 ≠ 组件渲染成功。
佐证:官方
dsh-client-ui-settings-plugins的PluginsSettingsSection就是register(options, ReactComponent)形态。super-injector 的「插件」面板用的 错误写法,实际同样空白。
命令式 DOM 逻辑保留方案(不必重写成全 React):React 组件外壳 + useEffect 挂载:
function WecomNotifyPanel() {
const hostRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = hostRef.current
if (!root) return
const page = buildPage() // 原有 document.createElement 构建逻辑
root.appendChild(page)
return () => { root.textContent = '' }
}, [])
return createElement('div', { ref: hostRef })
}
client bundle 可直接 import { useEffect, useRef } from 'react'(tsdown 保持
require("react") external,运行时由 dsh web 提供——官方插件同款)。
2. client 面板必须「包名装配」,file:// 入口带不上
浏览器端 client 模块由 @deepseek-ai/dsh-client-modules 发现:它扫描 host
Loader entries 中声明了 dsh.client 的包(entry.options.name 必须等于包名,
且有活跃 fiber 且未 disabled),读取 package.json:
dsh.client: { platform: 'web', inject: [...] }→ 进入 manifestexports["./client"]→ 定位lib/client.js路径- manifest 的 id = 包名,浏览器加载
/plugins/<包名>/client.js
因此:
- ❌ patch 挂载
name: 'file:///.../lib/index.js':entry 不是包 → 永远没有 client 面板(host 功能完整,仅无 UI)。 - ✅ 包名装配:junction 链接到 profile node_modules + bundles/dependencies 声明
(见 README「方式 A」)。
cordis.patch.yml里 entry 的name也要写包名。
ModuleLoader.load({ id: '包名', ... }) 的 id 必须与包名一致(tsdown banner 里的
PLUGIN_ID 就是包名),否则 manifest id 与注册 id 对不上。
3. tsdown client bundle 模板
// tsdown.config.ts
const clientBundle: UserConfig = {
entry: { client: 'src/client/index.ts' },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
clean: false,
deps: {
neverBundle: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client',
'cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-runtime/client'],
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
},
outputOptions: {
entryFileNames: 'client.js',
banner: 'window.__ModuleLoader__.load({ id: ' + JSON.stringify(PLUGIN_ID) + ', factory: (require) => {',
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
codeSplitting: false,
},
}
- 产物是 CJS + ModuleLoader banner(
module.exports里应有inject与apply)。 - 类型检查:host 的 tsconfig
exclude: ["src/client"];client 单独tsconfig.client.json(lib: ["es2022", "dom"]、types: [])。react 类型 解析依赖构建时的 junction link,缺失时 typecheck fails-soft(不影响产物)。
4. host 侧 webServer API
export const inject = ['tools', 'webServer'] // 声明服务
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/wecom-notify/api',
handler: async (req, res) => { /* ... */ },
}), 'wecom-notify: settings-api')
register返回 disposer(删除路由),配合ctx.effect卸载即清理。req/res是 Node http 的 IncomingMessage/ServerResponse;读 body 用for await (const c of req)。- 浏览器端同源
fetch('/wecom-notify/api/config')(相对路径)即可。 ctx.webServer不在@deepseek-ai/cordis基础类型里:declare module '@deepseek-ai/cordis' { interface Context { webServer: ... } }声明合并。
5. 配置设计:持久化文件 > cordis config > 环境变量
- 设置面板保存 →
<DSH_HOME>/wecom-notify/config.json(0600,含密钥)。 DSH_HOME 解析:process.env.DSH_HOME || ~/.dsh。 - 文件存在时整体优先(保存的是完整生效快照,面板里清空 = 显式清空);
删文件即回到静态配置。启动时
applyStored(loadConfig(config), readStoredConfig())。 - 热更新:notifier 持有可变配置引用,
updateConfig()整体替换—— 保存即生效,无需重启(配置 API 与发送调度解耦)。 - 兼容旧字段:单
webhookKey/WECHAT_WEBHOOK_KEY自动并入webhookKeys数组。
6. 开发环境隔离(DSH_HOME)
全局 profile 动不得时,用独立开发环境:
DSH_HOME=/path/to/.dsh-dev dsh web --port 3081 # 与全局 3080 错开
.dsh-dev里建profiles/web/(package.json + cordis.patch.yml), 装配方式与正式 profile 完全一致,但全部落在工作区,不污染~/.dsh。- 验证链:
curl /wecom-notify/api/config(GET/POST)→ 真实发送 → 重启看持久化source: "file"→curl /plugins/<包名>/client.js(面板资源 200)。
7. cordis.patch.yml 结构(loader patch 语义)
顶层必须是 YAML 数组([] 合法空态),元素两类:
# ① insert:插入新条目(无 id 则追加到顶层列表)
- insert:
- id: wechat-notify
name: 'dsh-wecom-notify' # ⚠️ bundle patch 里必须写包名!
config: {}
# ② 按 id 覆盖 / 禁用(applyEntryPatches:定位 → 合并字段;name 可做校验)
- id: wechat-notify
disabled: true
- 语义真源:
dsh-app-boot的applyEntryPatches(id 定位、insert 追加、name 不匹配跳过、匹配不到警告)。 - bundle patch(插件自带 cordis.patch.yml)里 entry name 写相对路径是错的:
相对路径基于 profile 目录解析(
<profile>/lib/index.js)——必须写包名, 由 profile node_modules 的 junction/依赖解析。 disabled支持!!js表达式(可对 env 求值)。
8. 工具链教训
| 坑 | 现象 | 对策 |
|---|---|---|
dev_uninject_plugin 会改写 profile patch | 曾把 - insert: 与子条目压成一行 → YAML 解析崩溃,dsh 命令全挂 | 动 patch 前先备份;卸载走完立刻核对文件;损坏就恢复备份 |
npm install 404(tsdown 传递依赖 @deepseek-ai/dsh-compact 未发布) | 装不上 tsdown | build.sh 自动回退 DSH_CHECKOUT/node_modules/.bin/tsdown |
| Node ≥22.6 直跑 .ts(node --test / scripts) | a ?? b || c 报 ERR_INVALID_TYPESCRIPT_SYNTAX | ?? 与 || 混用必须加括号 |
| 事件监听器抛异常会炸事件循环 | 插件崩溃 | 监听器体包 try/catch,永不抛出 |
client 类型漂移(SlotsService 等) | 依赖包版本升级即破 | 按使用面自写结构类型,构建不校验类型、运行时以实际服务为准 |
9. 验收清单(改完插件跑一遍)
npm test # 51 用例
npm run typecheck # host 类型
DSH_CHECKOUT=<dsh仓库> npm run build # host + client + client typecheck(fails-soft)
# 开发实例验证
DSH_HOME=<dev> dsh web --port 3081
curl <dev>/wecom-notify/api/config # GET 生效配置
curl -X POST <dev>/wecom-notify/api/config -d '{...}' # 保存(热生效 + 持久化)
curl -X POST <dev>/wecom-notify/api/test -d '{...}' # 真实发送,逐群结果
curl <dev>/plugins/<包名>/client.js # 面板资源 200
# 重启实例 → GET /config 应显示 source: "file"(持久化恢复)