Skip to content

代码约定

这些约定不是风格偏好——大多有对应的 harness 门禁(见开发流程),违反会直接红 CI。改动前先读根目录 AGENTS.md

TypeScript 与模块

  • 全仓库 TypeScript ESM。本地相对导入必须带 .js 扩展名
ts
import { DisposeStack } from './dispose.js';        // ✅
import { DisposeStack } from './dispose';           // ❌
  • Node 侧源码放 src/,构建产物放 lib/;浏览器侧源码放 client/,产物放 dist/
  • 新增 workspace 包必须落在 pnpm-workspace.yaml 覆盖的目录内,并带独立 package.json
  • pnpm-workspace.yamloverrides 承担大量安全版本抬升(undici、hono、tar、js-yaml、nodemailer 等),不要随手删改;新增依赖注意 pnpm check:dependency-policy 的约束。

usePlugin() 必须在模块顶层

usePlugin()@zhin.js/core)依赖 AsyncLocalStorage 定位调用方文件,只能在模块顶层调用,不能放进异步函数、回调工厂或延迟执行路径。门禁 pnpm check:use-plugin-top-level 会扫描 plugins/{adapters,features,utils,games}examples/{minimal-bot,test-bot}

ts
import { usePlugin } from 'zhin.js';   // zhin.js 根导出 re-export @zhin.js/core

const plugin = usePlugin();   // ✅ 模块顶层

约定式插件(definePlugin / defineCommand / defineAgentTool,来自 zhin.js/plugin-runtime 等子路径)在声明式 API 内不需要手动调 usePlugin()

getPlugin() 的禁用域

getPlugin()packages/im/core/src/plugin-context.ts)从 AsyncLocalStorage 取当前插件实例,只允许在初始化/装配阶段调用:

  • ✅ 允许:模块顶层、register/init 函数内、注册命令/中间件/工具之前
  • ❌ 禁止:中间件、命令 .action()、工具 .execute()、Cron、事件回调、生命周期 .on() 等运行时路径。

运行时回调应使用注册时捕获的 plugin / root 闭包引用。ALS 在跨 await、线程池、部分平台适配器回调中会丢失,运行时调用会抛 getPlugin() must be called within a plugin context。门禁:pnpm check:get-plugin-runtime

模块级状态:createGenerationStore

插件热重载意味着同一份模块代码会被多个 generation 先后使用。裸的模块级 let _x 单例会让新一代读到上一代已释放的资源,或让旧代卸载时误清掉新代的值。统一用 createGenerationStore<T>(name)@zhin.js/plugin-runtime):

ts
import { createGenerationStore } from '@zhin.js/plugin-runtime';

const dbStore = createGenerationStore<Database>('my-plugin-db');

// setup 阶段:provide 自动挂 context.lifecycle 反注册,
// 代际结束时该代的值被移除,上一代的值重新可见
dbStore.provide(context, db);

// 运行时路径(工具 execute、Cron、事件回调):读最新 live 值
const db = dbStore.use();      // 无值时抛出含 store 名的错误
const maybe = dbStore.tryUse(); // 无值时返回 undefined
  • 多代并存时栈顶(最新 live 注册)胜出,旧代先 dispose 不会误伤新代。
  • clear() 仅供测试复位。
  • 不要手写 if (!x) throw new Error('... not initialized')use() 已经带这个语义。

WS/SSE 端点:createEndpointLifecycle

长连接端点(napcat、milky、onebot11/12 这类 WS/SSE 适配器)的 start/stop/重连/心跳统一走 createEndpointLifecycle@zhin.js/adapter),不要手写状态机:

  • 状态机:idle → connecting → open → reconnecting → open … → stopped / closed
  • start(connectFn):连接失败自动复位回 idle 且不武装重连。
  • stop():清全部定时器、调用强关函数、唤醒竞态等待,绝不重连。
  • handle.notifyClosed():对端断开时由适配器调用,仅在连接曾 open 时按指数退避 + jitter 重连。
  • startHeartbeat(fn, interval):心跳 + 看门狗,连续 N 轮无回包(未 notifyHeartbeatAck())时主动强关,由 close 事件驱动重连。
  • 退避参数可配:initialIntervalMs(默认 5000)、multiplier(默认 2)、maxIntervalMs(默认 60000)、jitterMsmaxAttempts(默认 Infinity)。

适配器专有的逻辑(如 agent 注册/反注册)留在适配器侧,start 失败时要对称反注册。

消息统一链路

所有出站消息必须走统一链路,禁止旁路发送(门禁 pnpm check:harness-paths):

跨平台出站(从一个平台发到另一个平台)用 root.inject(adapter).sendMessage,不要直接操作 Endpoint。

Host token 模式

HTTP Host(@zhin.js/host-http)不内置会话体系,统一用 Bearer token 鉴权:

  • 客户端请求带 Authorization: Bearer <token>,token 来自 http.token 配置;服务端用 TokenRegistrypackages/host/http/src/token-registry.ts)校验,extractBearerToken 解析头。
  • token 比对走 timingSafeEqualString(常数时间比较),不要手写 === 比对。
  • token 按 scope 分级(ScopedTokenConfig / AuthScope):写操作要求 full scope,demo token 一律 403。
  • Remote Console 登录 = API Base URL + Bearer Token,没有账号密码概念。

测试约定

  • 测试用 Vitest,配置在根 vitest.config.tsglobals: true(无需 import describe/it/expect)、environment: 'node'、匹配 **/*.test.ts
  • 文件级隔离开启(isolate: true),避免 vi.spyOn / vi.mock 跨文件泄漏;写测试时不要依赖跨文件的全局状态。
  • 覆盖率阈值(v8 provider):lines 45% / branches 35%。
  • 数据库回归优先用真实 SQLitebasic/database 的测试用 Node 内置 node:sqliteDatabaseSync 跑真实方言(需要 Node 22.5+,推荐 24+;版本不足时跳过),而不是 mock 掉 SQL 层。
  • 单包测试优先 pnpm --filter <pkg> test;全量 pnpm test