跳到主要内容
路径文档

编写并发布 Typert 契约

一句话版:把插件能力声明成 Typert Remote 契约并发布,走五步——① 用 @typert object / @typert service <key> / declarations 的 export JSDoc 标出公开面;② 用 ctx.typert.lookups.register() / ctx.typert.contexts.* 注册身份;③ 构建时用 WorkspaceTypertGeneratorcheck 校验失败即报错、write 自动补类型注解)生成 lib/typert.host.{js,d.ts}lib/typert.remote-client.*;④ 在 package.jsonexports 暴露 ./typert./remote;⑤ 消费侧用 Remote API 网关ctx.remote.$mount()作者负责契约,消费侧只读产物。

这是"作者侧"(插件作者把自己的能力声明、生成、发布成 Typert Remote 契约)。消费侧怎么用已生成的产物见 Remote API 网关。双 face 边界见 插件解剖

〇、从哪个包读到什么

Typert 把"源码分析、运行时存储、产物生成"分成三个包(packages/typert/README.md):

职责Cordis 键本文角色
packages/typert/protocol不依赖编译器的协议声明(@Remote@RemoteScopeInvocationDescriptorTypertLookupMaptypertRemote 绑定)无(纯类型 + 装饰器)作者写源码时用的标注词汇表
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):只暴露公开实例成员;构造函数、静态成员、非公开成员一律被排除(generator README.md)。
  • @typert schema / @typert type@typert 后无词或为 schema/type)—— 标为数据 schema,生成可执行的 Zod 投影。
  • @typert service <key> —— 把导出的标为服务<key> 就是这个服务的 Cordis 键;必须是恰好一个非空、不带 / 的段,且类是 export 的类(否则 analyzer.ts collectExplicitServices 直接 fail)。
  • 源码里 declare module '@deepseek-ai/cordis' { interface Context { … } interface Events { … } } —— Context 扩充给出服务,Events 扩充给出事件,两者都被收入该包的反射面。

真实例子——packages/core/session/src/index.tsSession 类标注与 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: SessionStoreContext 扩充里——前者进反射对象、后者作为服务进包模型。两者都会被生成器发现。

@typert object 类型图的 Zod 侧也要留意:FaceModelEmitter 只支持它说支持的那一类 TypeScript 投影(关键字/字面量/数组/元组/联合/交叉/接口/Record/Date 等);不支持对象、函数、Map/Set/Date 实例、泛型 schema 根、条件/映射类型根等(generator emitter.tsfail 而不是展平或弱化源类型)。参数可见性绕不开:服务与对象只保留 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' 再跑一遍)。产物生成器 WorkspaceTypertGeneratorpackages/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):

子表方法谁注册什么
lookupsregister(key, provider) / configure(key, resolver)宿主对象↔wire 身份的解析(含默认 resolver)
contextsregisterHost / configureHost / registerClient作用域 Context↔wire 身份(Host 解析 provider / Client 绑定器)
localget / list / hasSeen / subscribe当前环境的 InvocationDescriptorregister() 灌入)
remotesregister(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/sessionpackages/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 注册/查询一览(registry README.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(含 TYPERT contribution 与支持它的 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.jsonpackages/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.jsondsh.client),可同时贡献 Host 与 Client 运行时模型;只有被 tsconfig.host.jsontsconfig.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.jsHost 反射产物(Loader/宿主用)
./remote./lib/typert.remote-client.d.ts + ./lib/typert.remote-client.jsHost-for-Client Remote 投影(消费侧导入)

packages/feedback/message-feedback/package.jsonpackages/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 modulewire↔对象解析可用
3 构建Host tsdown 跑 typertPlugin({mode:'workspace',faces:['host']})(仓库已配好)lib/typert.host.* + lib/typert.remote-client.*
4 发布package.json 暴露 ./typert./remotefiles 带上它们消费者可导入
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 清单
  • 写一个服务:服务是能力的基本容器