编写并发布 Typert 契约
一句话版:把插件能力声明成 Typert Remote 契约并发布,走五步——① 用
@typert object/@typert service <key>/ declarations 的exportJSDoc 标出公开面;② 用ctx.typert.lookups.register()/ctx.typert.contexts.*注册身份;③ 构建时用WorkspaceTypertGenerator(check校验失败即报错、write自动补类型注解)生成lib/typert.host.{js,d.ts}与lib/typert.remote-client.*;④ 在package.json的exports暴露./typert与./remote;⑤ 消费侧用 Remote API 网关 的ctx.remote.$mount()。作者负责契约,消费侧只读产物。
这是"作者侧"(插件作者把自己的能力声明、生成、发布成 Typert Remote 契约)。消费侧怎么用已生成的产物见 Remote API 网关。双 face 边界见 插件解剖。
〇、从哪个包读到什么
Typert 把"源码分析、运行时存储、产物生成"分成三个包(packages/typert/README.md):
| 包 | 职责 | Cordis 键 | 本文角色 |
|---|---|---|---|
packages/typert/protocol | 不依赖编译器的协议声明(@Remote、@RemoteScope、InvocationDescriptor、TypertLookupMap、typertRemote 绑定) | 无(纯类型 + 装饰器) | 作者写源码时用的标注词汇表 |
packages/typert/registry | 生成的反射与 Zod schema 运行时注册表 | ctx.typert | 作者/宿主在运行时注册身份与契约 |
packages/typert/generator | 从源码类型生成运行时产物的构建时库 | 无(tsdown 插件) | 构建阶段生成与校验产物 |
加上 packages/typert/loader:它在 Loader 组合里发现各包 ./typert 导出并调用 ctx.typert.register(),把生成的产物自动灌进注册表(详见 packages/typert/loader/README.md)。作者侧只要保证"产物 + 导出声明的位置",Loader 会替你做运行时注册。
完整闭环:
图中的"消费侧网关/客户端"已在 Remote API 网关 讲透;本篇停在作者侧每一步该写什么、去哪看产物、怎么验证。
一、源标注:把能力标成公开面
Typert 分析的是包公开导出(package.json#exports 指向的源码图),不是任意内部文件。packages/typert/generator/src/analyzer.ts 里的 typertMode() 与 typertServiceTag() 负责读 JSDoc 标注:
@typert object—— 把一个导出的对象(类等)标为反射对象(ObjectModel):只暴露公开实例成员;构造函数、静态成员、非公开成员一律被排除(generatorREADME.md)。@typert schema/@typert type(@typert后无词或为schema/type)—— 标为数据 schema,生成可执行的 Zod 投影。@typert service <key>—— 把导出的类标为服务,<key>就是这个服务的 Cordis 键;必须是恰好一个非空、不带/的段,且类是export的类(否则analyzer.tscollectExplicitServices直接fail)。- 源码里
declare module '@deepseek-ai/cordis' { interface Context { … } interface Events { … } }—— Context 扩充给出服务,Events 扩充给出事件,两者都被收入该包的反射面。
真实例子——packages/core/session/src/index.ts 的 Session 类标注与 Cordis 扩充:
// packages/core/session/src/index.ts
declare module '@deepseek-ai/cordis' {
interface Context { sessions: SessionStore }
interface Events {
'session/created'(this: Scoped<Session>, session: Session): void
'session/disposed'(this: Scoped<Session>, session: Session): void
/* ... */
}
}
/**
* 一个基于事件溯源的会话:SessionEvent 的只追加日志。
* @typert object
*/
export class Session {
/** 会话在 durable header 里那份唯一副本上派生的标识。 */
get id(): SessionId { return this.header.id }
/* ... */
}
注意
@typert object标注的是Session这个类,而sessions: SessionStore在Context扩充里——前者进反射对象、后者作为服务进包模型。两者都会被生成器发现。
@typert object 类型图的 Zod 侧也要留意:FaceModelEmitter 只支持它说支持的那一类 TypeScript 投影(关键字/字面量/数组/元组/联合/交叉/接口/Record/Date 等);不支持对象、函数、Map/Set/Date 实例、泛型 schema 根、条件/映射类型根等(generator emitter.ts 会 fail 而不是展平或弱化源类型)。参数可见性绕不开:服务与对象只保留 public 非静态实例成员。
二、check 模式:四类失败 & write 模式自动补注
WorkspaceAnalyzer 默认 check 模式(packages/typert/generator/src/analyzer.ts:282 mode: options.mode ?? 'check')。遇到四类情况之一直接失败(返回 TypertAnalysisError,附面向诊断的行列信息):
| 失败来源 | 源码动作/定位 | 修法 |
|---|---|---|
| TypeScript 语法/语义诊断 | checkProject() 收集每个包的 getSyntacticDiagnostics() + getSemanticDiagnostics() | 修好类型错误 |
| 可达公开声明缺显式类型注解 | requiredType():check 模式 fail('public … is missing an explicit type annotation') | 显式标注类型(或开 write 让它补) |
| 跨包私有引用 | 模型只从公开导出可达;穿到别包私有边界的引用会被拒 | 走 @typert/公开导出,不外泄私有 |
| 无法无损保留的声明合并 | 模型保留声明身份/泛型/继承等,可无损保留的合并(如 TypertLookupMap)支持;不能无损的(如跨 face 命名空间重导出、merged … is not supported)失败 | 改为具名导出/确定的导出目标 |
write 模式是补注的自动档:当 mode: 'write',requiredType() 不再 fail,而是用类型检查器 checker.typeToTypeNode() 推导类型并 queueEdit() 在源码对应位置插入 : <rendered> 注解,然后重编译、返回一份干净的 check 模式模型(analyzer.ts analyze() 尾部 mode: 'check' 再跑一遍)。产物生成器 WorkspaceTypertGenerator(packages/typert/generator/src/workspace.ts)由 tsdown 插件驱动,默认执行 generate()(check 面);要补注才显式开 write。
三、身份注册:ctx.typert.lookups / contexts
运行时注册表 TypertRegistry(默认插件,提供 ctx.typert)来自 packages/typert/registry/src/service.ts。作者包在运行时把"宿主对象/作用域 Context ←→ wire 身份"的对应关系注册进去,生成的 InvocationDescriptor 才能把 wire 参数换成宿主对象。
ctx.typert 四个子表(packages/typert/registry/README.md + service.ts):
| 子表 | 方法 | 谁注册什么 |
|---|---|---|
lookups | register(key, provider) / configure(key, resolver) | 宿主对象↔wire 身份的解析(含默认 resolver) |
contexts | registerHost / configureHost / registerClient | 作用域 Context↔wire 身份(Host 解析 provider / Client 绑定器) |
local | get / list / hasSeen / subscribe | 当前环境的 InvocationDescriptor(register() 灌入) |
remotes | register(contribution) / get / list | 消费侧选定并挂载的 Remote contribution |
lookups.register(key, provider) 的 provider 字段(protocol/src/types.ts TypertLookupProvider):parameter(源码参数名)、wire(wire 字段名)、hostTypeSymbol / wireTypeSymbol(严格生成用的规范类型符号)、resolve(id)(把 wire 身份解析成 Host 对象,可同步/异步)。register 在提交前会校验非法标识与重复键,返回一个精确的 Cordis effect disposer(随注册 fiber 卸载自动撤销)。
仓库里最典型的 session lookup 范例就是 core/session(packages/core/session/src/index.ts:798-806):
// packages/core/session/src/index.ts
export class SessionStore extends Service {
constructor(ctx: Context) {
super(ctx, 'sessions')
ctx.inject(['typert'], (typeCtx) => {
typeCtx.typert.lookups.register('session', {
parameter: 'session',
wire: 'sessionId',
hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
resolve: sessionId => this.get(sessionId),
})
})
}
/* ... */
}
配套的静态声明合并在其 declare module '@deepseek-ai/dsh-typert-protocol' 里(同文件 89-93 行):interface TypertLookupMap { session: TypertLookup<Session, SessionId> }。协议包两侧都由作者持有(protocol/README.md):声明合并给静态关联,运行时 provider 向 ctx.typert 注册身份解析。包作者(协议包)提供稳定声明与默认 resolver,宿主组合可以 configure() 覆盖为同步或异步 resolver,且配置可先于 provider 存在;策略拒绝可用 TypertLookupFailure 携带边界适配器拥有的失败值。
ctx.typert注册/查询一览(registryREADME.md公开 API):register(contribution)、get(key)、resolve(key)、list(filter?)、getPackage(packageName, face='host')、listPackages(filter?)、toJSONSchema(key, params?)(用z.toJSONSchema()按需投影、不缓存)、typertKey()/typertPackageKey()。schema 以<package>#<name>为键、包反射以<package>#<face>为键。
四、构建:Host 产物 + Remote 投影 & dual-face
产物由 WorkspaceTypertGenerator + FaceModelEmitter 生成(packages/typert/generator/src/workspace.ts + emitter.ts)。Host tsdown 以 tsconfig.host.json 为唯一 program 种子运行生成,同时产出:
- Host 反射产物
lib/typert.host.js+lib/typert.host.d.ts(含TYPERTcontribution 与支持它的 Zod schema;TYPERT在 d.ts 里暴露为unknown,这样业务包不必依赖运行时注册表)。 - Host-for-Client Remote 投影
lib/typert.remote-client.js+.d.ts(+.d.ts.map):把 Host 的 Remote 契约投影给 Client,让 Client 导入后只看到选定的 Remote 方法签名。
root package.json(packages/typert/generator 所在仓库根)的构建脚本印证了这套划分:
// package.json (scripts)
"build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host",
"build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client",
并且 root tsdown.config.ts 里 Host pass 挂 typertPlugin({ mode: 'workspace', faces: ['host'] }),Client pass 的 plugins 是空数组(client ? [] : [typertPlugin(...)])——即 Client 阶段既不再启动 Typert,也不分析 tsconfig.client.json,与 generator README.md 完全一致。
dual-face(Host/Client 双面)边界:一个普通单项目包若在 package.json 里声明 dsh.client(如 packages/api/gateway/package.json 的 dsh.client),可同时贡献 Host 与 Client 运行时模型;只有被 tsconfig.host.json 或 tsconfig.client.json 显式 project reference 的拆分项目才被限制到对应 face。package.json#exports 决定每个跨包公开边界,源码 import/re-export 是唯一合法的跨 face 边;跨 face、直连别包私有引用的会失败。远程网关/客户端那侧的双 face 装配见 Remote API 网关,插件 dual-face 形态见 插件解剖。
五、发布:package.json 暴露 ./typert 与 ./remote
生成器在发布前会校验导出声明的一致性(workspace.ts validateExport()):要求包在 files 中带上对应产物文件。package.json#exports 应当这样暴露产物(这是作者侧必须补上的声明;hasTypertExport() 也靠它识别该包是否参与生成):
| 子路径 | 指向 | 用途 |
|---|---|---|
./typert | ./lib/typert.host.d.ts + ./lib/typert.host.js | Host 反射产物(Loader/宿主用) |
./remote | ./lib/typert.remote-client.d.ts + ./lib/typert.remote-client.js | Host-for-Client Remote 投影(消费侧导入) |
packages/feedback/message-feedback/package.json 与 packages/host/plugin-inventory/package.json 的实例:
// packages/host/plugin-inventory/package.json
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./types":{ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" },
"./typert": {"types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" },
"./remote": {"types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" },
"./package.json": "./package.json"
},
"files": [ "lib/index.js", "lib/typert.host.js", "lib/typert.host.d.ts",
"lib/typert.remote-client.js", "lib/typert.remote-client.d.ts", /* ... */ ]
发布是包自选(opt-in):没有这些公开入口的业务包不需要 Typert 产物,生成器直接跳过。Remote 方法在 packages/feedback/message-feedback/src/index.ts 里的标注方式正是消费侧签名来源:
// packages/feedback/message-feedback/src/index.ts
export class MessageFeedbackService extends TypertRemoteService {
@Remote('list') async list(request: MessageFeedbackListRequest): Promise<MessageFeedbackListResult> { /* ... */ }
@Remote('put') put( request: MessageFeedbackPutRequest): Promise<MessageFeedbackPutResult> { /* ... */ }
@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise<MessageFeedbackDeleteResult> { /* ... */ }
}
服务继承 TypertRemoteService 即把 super(ctx, serviceKey) 的 Cordis 键绑定到同一默认 wire namespace;不能继承的用 bindTypertRemote(this, serviceKey) 得到同样只读冻结的 typertRemote 绑定(protocol/src/index.ts)。@Remote() 还支持显式导出名,@RemoteScope(key) 标记从作用域 Context 取接收者的方法。
六、端到端最小改动清单
| 步骤 | 你要改的文件/动作 | 产物/效果 |
|---|---|---|
| 1 标注 | 在被导出类/对象前写 @typert object,服务前写 @typert service <key>,Remote 方法打 @Remote | 公开面进模型 |
| 2 注册 | 运行时 ctx.typert.lookups.register('session', {parameter, wire, hostTypeSymbol, wireTypeSymbol, resolve});作用域用 contexts.registerHost/registerClient;同名协议 declare module | wire↔对象解析可用 |
| 3 构建 | Host tsdown 跑 typertPlugin({mode:'workspace',faces:['host']})(仓库已配好) | lib/typert.host.* + lib/typert.remote-client.* |
| 4 发布 | package.json 暴露 ./typert、./remote,files 带上它们 | 消费者可导入 |
| 5 消费 | 消费侧 ctx.remote.$mount() + 网关(见下) | 作者到消费者的闭环 |
七、验证
# 1) Host 构建跑 Typert,产物应出现在声明过的包
npm run build:lib:host
ls packages/host/plugin-inventory/lib/typert.host.{js,d.ts} \
packages/host/plugin-inventory/lib/typert.remote-client.*
# 2) 发布声明必须与产物一致,否则 validateExport 报错
node -e "const m=require('./packages/host/plugin-inventory/package.json');
console.log('typert→', m.exports['./typert'], '\nremote→', m.exports['./remote'])"
# 3) 运行时注册表可见(启动 dsh 后):
# lookups.get('session') 应返回 provider(parameter/wire/hostTypeSymbol/wireTypeSymbol)
# local.list() / listPackages() 列出的包面键是 <package>#<host>
# 4) 源码分析单独跑(不需要运行时):
# 仓库里直接调 WorkspaceAnalyzer,check 面失败会带诊断退出,write 面自动补注后返回干净模型
下一步
- Remote API 网关:消费侧——
ctx.typertGateway.invoke()、ctx.remote.$mount()/$on()/$dispatch()怎么消费这份产物 - 插件解剖:dual-face(Host/Client)边界的插件形态与
dsh清单 - 写一个服务:服务是能力的基本容器