夜雨聆风学习资料网

ARTICLE · 1021667

DeepSeek Harness 源码-Typert RPC 网关

DeepSeek Harness 源码-Typert RPC 网关

DeepSeek Harness 源码解析系列

第 33 讲:Typert RPC 网关

基于 DeepSeek Harness 源码 · 2026-09-16

💡 本讲一句话:Typert 网关是 Host 与 Client 之间的"海关"——它把一条 /api/goals/edit 的 wire 调用,仲裁成"调哪个 Service、参数里的 id 换成什么实体、返回值过不过 schema",全程 fail loud:多一个字段、少一个字段、provider 对不上,都拒绝执行而不是猜。

一、网关解决什么问题

前几讲我们拆过 Goal、Session、Credentials 这些能力包,它们都是 Host 进程里的 Cordis Service。但 Web 客户端、Python SDK、ACP 自动化都在另一个进程(甚至另一台机器)——它们不能直接 ctx.get('goals'),只能发一条 RPC。

Typert 网关(@deepseek-ai/dsh-api-gateway)就是这条 RPC 在 Host 侧的落点。它要回答四个问题:

🔹 这个 endpoint 归谁管?——/api/* 前缀下可能有多个网关,每个 Service 只认领自己导出的方法。

🔹 按什么契约执行?——参数名、可选性、返回类型从哪来?答案是双轨:编译期生成的 strict 定义优先,运行时 SRC marker 推导兜底。

🔹 wire 上的 id 怎么变成活对象?——agent、session 这类参数在 wire 上只是字符串 id,网关负责通过 lookup provider 换回 Host 实体。

🔹 失败怎么报?——错误码是封闭集合(service-unavailable、lookup-not-found…),调用方可以机读地分类处理,而不是解析报错文本。

本讲地图:先看 wire 契约(RemoteResult / codec),再看声明侧的 @Remote 装饰器怎么把方法"挂牌",然后进网关内部:认领 endpoint → 解析描述符 → 对账参数 → lookup 换实体 → JSON-safe 断言 → envelope 返回。最后看 typert-loader 如何自动发现并注册这些导出。

所有代码都来自 

packages/typert/protocol、packages/api/gateway、packages/typert/loader,行号以当前仓库为准。

二、wire 契约:RemoteResult 与 codec

网关和客户端共享一份"编译器无关的协议层"——@deepseek-ai/dsh-typert-protocol/types。它不依赖任何具体 carrier(WebSocket、HTTP 都行),只定义两端必须遵守的形状。最核心的是每个 Remote 方法的解析结果:

📄 packages/typert/protocol/src/types.ts (第 47-62 行)

export interface RemoteFailure {  // 一次远端调用的失败面:carrier 上报时的原始形状  readonly code: string  // code 在这里故意保持开放字符串——封闭的 RPC 码联合属于 carrier 包,写死会反转依赖方向  readonly message: string  // 面向调用方的诊断文本,不含敏感值  readonly details: object  // 结构化细节槽位,由业务方决定放什么}  // 结束该分支块export type RemoteResult<T> =  // 每个生成的 Remote 方法最终都解析成这个二元结果  | { readonly ok: true; readonly value: T }  // 成功分支:value 是 Host 方法的业务返回值  | { readonly ok: false; readonly error: RemoteFailure }  // 失败分支:carrier 的失败已折叠进这里,消费方无需再包一层 try/catch

为什么这样设计:把"业务失败"折叠进 RemoteResult 的 error 分支,而不是让 RPC 通道抛异常,是因为跨进程后异常的类身份必然丢失——instanceof GoalError 在客户端永远为假。于是协议层规定:失败必须是一个数据(code + message + details),调用方按 code 分类处理。注释里那句"no consumer wraps a call to recover one"点明了意图:消费侧的 API 面是干净的,只有装配故障(arity、方法未挂载)才走 reject。

与 codec 相关的另一个关键类型是 TypertCodec:它只有两种模式——{ mode: 'strict', typeSymbol, schema }(带 zod v4 schema,边界上必须 parse)和 { mode: 'src-json' }(无 schema,只做 JSON-safe 断言)。这两种模式贯穿网关的入参、出参、lookup、context 四个位置,是后面所有分支判断的根源。

维度
strict(生成定义)
SRC(marker 推导)
描述符来源
registry local store(typert-loader 自动注册 ./typert artifact)
运行时扫描 typertRemote 绑定 + @Remote marker
参数 wire 名
生成时确定的 wire 字段(可与源码参数名不同)
直接读 JS 函数签名(Function.prototype.toString),wire 名 = 参数名
可选参数
acceptsUndefined
 显式声明,缺字段精确放行
看不到可选性,json 字段一律允许缺席;必填检查推迟到 LIB 侧
返回值校验
schema.parse()
 + JSON-safe 断言
仅 JSON-safe 断言(src-json codec)
strict 定义撤回后
hasSeen
 命中 → 禁止 SRC 兜底,报 definition-unavailable
—(永远不能"半严格")
适用场景
生产装配:包导出了 ./typert artifact
源码直跑 / 测试:没有生成产物时的保守降级

这张表是本讲后面所有分支的总纲:descriptor.result.mode === 'strict'、parameter.codec.mode === 'src-json'、acceptsUndefined——网关里几乎每个 if 都在回答"我现在走的是哪一轨"。

三、声明侧:TypertRemoteService 与 @Remote

一个 Service 想被远程调用,要做两件事:继承 TypertRemoteService(声明"我参与 Gateway 导出")+ 给方法打 @Remote 装饰器(声明"这个方法可导出")。两者都只写私有模块状态,不依赖任何编译器注入的元数据——这是 protocol 包注释里反复强调的 "backed only by private module state"。

📄 packages/typert/protocol/src/index.ts (第 135-161 行)

export function bindTypertRemote<Service extends object>(  // 把一个可见 Service 字段绑定到 Cordis key 和 Remote namespace  service: Service,  // 持有该方法的 Service 实例,通常是 this  serviceKey: string,  // 精确的 Cordis 服务键,也是默认 wire namespace  options: TypertGatewayBindingOptions = {},  // 可选:用不同的 wire namespace 覆盖默认值): TypertGatewayBinding<Service> {  // 返回冻结、可检查的绑定对象  validateName('service key', serviceKey)  // 先校验键名只含 RPC endpoint 合法字符,非法直接抛错  const namespace = options.namespace ?? serviceKey  // 没显式指定 namespace 就沿用服务键——默认即约定  validateName('namespace', namespace)  // namespace 同样过一遍字符集校验  return Object.freeze({ service, serviceKey, namespace })  // freeze 保证绑定不可被运行时篡改,Gateway 侧可安全信任}  // 结束该分支块export abstract class TypertRemoteService<out T = never> extends Service<T> {  // Cordis Service 基类:注册时顺带暴露 typertRemote 绑定  readonly typertRemote: TypertGatewayBinding<this>  // Gateway 的 SRC 发现路径靠读这个字段找到"谁在导出 Remote"  protected constructor(ctx: Context, serviceKey: string, options: TypertGatewayBindingOptions = {}) {  // 子类构造时传入服务键    super(ctx, serviceKey)  // 先完成 Cordis Service 的常规注册    this.typertRemote = bindTypertRemote(this, this.name, options)  // 再用同一个 key 绑定 Gateway——一处声明,两处生效  }  // 结束该分支块}  // 结束该分支块

为什么这样设计:typertRemote 是一个可见的、冻结的普通字段,而不是装饰器偷偷挂在原型上的私有属性。好处是网关做 SRC 发现时只需要 Reflect.get(original, 'typertRemote') 一次读取就能判断"这个 Service 参不参与 Remote 导出"——不需要遍历、不需要约定方法名前缀。而 Object.freeze + 构造期绑定保证了:运行时没有任何代码能改 namespace,网关读到的就是声明时的样子。

再看 @Remote 装饰器。它不修改方法本身,而是往一张 WeakMap 里登记 marker——注意时机:标记发生在实例化时(通过 addInitializer),不是类定义时:

📄 packages/typert/protocol/src/index.ts (第 126-269 行,节选)

const markers = new WeakMap<object, Map<string, StoredRemoteMethodMarker>>()  // 私有标记表:原型 → {方法名 → marker},WeakMap 让 Service 可被 GCfunction addMarkerInitializer<This extends object>(  // 装饰器入口:把"标记动作"推迟到实例化时执行  context: RemoteInitializerContext<This>,  // 标准 TC39 装饰器上下文,提供 addInitializer  invocation: RemoteInvocationMarker,  // direct(直调)或 context(按 Context 解析接收者)  exportName?: string,  // 可选导出名:endpoint 方法名与实现成员名解耦): void {  // 装饰器入口本身不做事——只注册实例化钩子,标记推迟到构造期执行  if (context.private || context.static || typeof context.name !== 'string') {  // Remote 只允许"公有实例方法 + 字符串名",否则无法按名字寻址    throw new TypeError('typert-protocol: Remote decorators require a public instance method with a string name')  // fail loud:装饰器用错位置立即报错,不留到运行时才发现  }  // 结束该分支块  const method = context.name  // 记下方法名,稍后在原型上登记  context.addInitializer(function (this: This) {  // 注册实例初始化钩子——标记发生在构造时而非类定义时    const prototype = Object.getPrototypeOf(this) as object | null  // 取实例原型:marker 挂在原型上,全实例共享一份    if (prototype === null) {  // 无原型的对象(Object.create(null))无法登记      throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`)  // 明确报错而不是静默丢失导出    }  // 结束该分支块    mark(prototype, method, invocation, exportName)  // 真正写入私有标记表  })}  // 结束该分支块function mark(prototype: object, method: string, invocation: RemoteInvocationMarker, exportName?: string): void {  // 把 marker 写进原型级 Map  let table = markers.get(prototype)  // 查该原型已有的标记表  if (table === undefined) {  // 首次出现:建表并挂到 WeakMap    table = new Map()  // 每个原型一张表,该类所有实例共享    markers.set(prototype, table)  // 挂上 WeakMap——Service 卸载后整张表随原型一起回收  }  // 结束该分支块  const marker: StoredRemoteMethodMarker = {  // 组装最终 marker;exportName 与实现名相同时省略,省一个字段    ...(exportName === undefined || exportName === method ? {} : { exportName }),  // 条件展开:只有别名导出才写 exportName    invocation: Object.freeze(invocation),  // freeze:调用模式不可变,Gateway 读到的就是声明时的样子  }  // 结束该分支块  const current = table.get(method)  // 同名方法被重复装饰?  if (current !== undefined) {  // 已有 marker    if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return  // 完全相同的重复声明幂等放行    throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`)  // 冲突(如 direct vs context)直接抛错,拒绝二义性  }  // 结束该分支块  table.set(method, Object.freeze(marker))  // 落表并冻结}  // 结束该分支块

为什么这样设计:三个细节值得注意。其一,marker 挂在原型而不是实例上——同一个 Service 类的所有实例共享一份导出声明,网关扫一次原型就够;WeakMap 又保证插件卸载、Service 被 GC 时标记自动消失,不会泄漏。其二,addInitializer 把登记推迟到构造期:装饰器在类定义阶段执行,那时 this 还不存在,而 marker 表需要以"活实例的原型"为键——这个时序选择让 remoteMethods(service) 的读取语义变成"只报告已实例化的 Service"。其三,冲突检测在声明期就抛错:同一个方法既标 direct 又标 context 是装配错误,拖到网关运行时才暴露就晚了。

落到真实业务包上,整个声明面只有两行。以 Goal 服务为例:

📄 packages/goal/goal/src/index.ts (第 183-194、276-282 行)

export class GoalService extends TypertRemoteService {  // 继承基类 = 自动获得 typertRemote 绑定,namespace 默认 'goals'  static inject = ['agents']  // 声明依赖:Cordis 注入 agents 服务  constructor(ctx: Context, config: Config = {}) {  // 常规 Service 构造    super(ctx, 'goals')  // 注册为 'goals',同时绑定 wire namespace 'goals'——endpoint 前缀由此而来  }  // 结束该分支块  @Remote('edit')  // 把 edit() 标记为 Remote 导出;exportName='edit' 与实现同名  edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView {  // 客户端将调用 goals/edit,wire args 按参数名命名    const cache = this.prepareMutation(agent)  // 取该 session 的 goal 变更缓存(业务逻辑)    const current = this.expectCurrent(cache, ref)  // 校验 ref.revision 与当前一致——乐观并发控制    if (request.objective === undefined && request.maxGoalRounds === undefined) {  // 两个字段都没给 = 空编辑,无意义      throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT')  // 业务错误带稳定 code,网关会原样透传给客户端    }  // 结束该分支块  }  // 结束该分支块}  // 结束该分支块

为什么这样设计:注意 @Remote('edit') 的显式 exportName——它让"wire 上的方法名"和"类里的实现成员名"解耦。业务代码可以重命名内部方法而不破坏 wire 契约;反过来,网关执行时用 descriptor.implementation ?? descriptor.method 找到真正的实现。另外这个例子里 agent: Agent 参数在 wire 上只是一个 id 字符串——它怎么变回活对象?这正是下一节网关内部要干的事。

小结:声明侧的全部产物 = 一个冻结的 typertRemote 绑定 + 原型上的一批 marker。没有全局注册表、没有字符串约定、没有反射魔法——网关发现导出靠的是"读字段 + 查 WeakMap"两个纯数据操作,这也是它能在 HMR / 插件卸载场景下保持正确的前提。

四、网关注册与 endpoint 认领

TypertGatewayService 本身也是一个 Cordis Service(键 typertGateway)。它的构造函数只做一件事:在 connection 的 RPC 通道上挂拦截器,认领 /api 前缀下属于自己的 endpoint:

📄 packages/api/gateway/src/index.ts (第 90-137 行)

export class TypertGatewayService extends Service implements TypertGateway {  // Gateway 本身也是一个 Cordis Service(键 'typertGateway')  static inject = ['typert']  // 依赖 typert registry:strict 定义、lookup、context 都从它读  constructor(ctx: Context) {  // 构造即完成全部接线,无独立 start 阶段    super(ctx, 'typertGateway')  // 注册服务键    ctx.on('internal/service', () => {  // 监听服务表变化事件      this.srcClaims = undefined  // 缓存失效:SRC endpoint 集合随 Service 增删而变,必须重算    })    ctx.inject(['connection'], (connectionCtx) => {  // 拿到 connection 后在 RPC 通道上挂拦截器      connectionCtx.connection.rpc.intercept(  // intercept('/api', ...) = 认领 /api/* 前缀下的 endpoint        '/api',  // 命名空间前缀:Typert 网关只处理 /api 下的调用        endpoint => this.claimsEndpoint(endpoint),  // claimer:判断某个具体 endpoint 是否归本 Gateway        (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),  // dispatcher:真正执行并返回 envelope        { authority: 'trusted-host' },  // 权限标记:只有受信 Host 通道能触发,外部不可伪造      )    })  }  // 结束该分支块  private claimsEndpoint(endpoint: string): boolean {  // 回答"这个 endpoint 我接不接"    const segments = endpoint.split('/')  // endpoint 语法是 <namespace>/<method> 两段    if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false  // 段数不对或空段:不是合法 Remote endpoint,直接不认领    if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true  // strict 定义在册(含已撤回)都算我的——hasSeen 防止撤回后被 SRC 路径偷接    this.srcClaims ??= this.collectSrcClaims()  // 首次需要时惰性收集 SRC marker endpoint,之后缓存    return this.srcClaims.has(endpoint)  // SRC 标记命中才认领  }  // 结束该分支块  private collectSrcClaims(): ReadonlySet<string> {  // 扫描所有活跃 Service,找出带 typertRemote 绑定 + Remote marker 的导出    const claims = new Set<string>()  // 收集 endpoint 集合    for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {  // 遍历反射表里的全部服务定义      if (definition.type !== 'service') continue  // 只关心 service 类型条目      const receiver = this.ctx.get(serviceKey) as unknown  // 取活实例;ctx.get 读全局服务存储,不依赖注入拓扑      if (!isObject(receiver)) continue  // 未激活/不可用:跳过      const original = originalOf(receiver)  // 剥掉 Cordis 代理层,拿到原始对象(marker 挂在原对象原型上)      const binding = Reflect.get(original, 'typertRemote') as unknown  // 读 typertRemote 绑定字段——没有就不是 Remote Service      if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue  // 绑定残缺:跳过,不猜      const namespace = Reflect.get(binding, 'namespace') as string  // 读出 wire 命名空间(namespace),后面拼 endpoint 用      for (const candidate of remoteMethods(original)) {  // 枚举该 Service 上所有 @Remote marker        claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))  // 拼出 <ns>/<method>;exportName 优先(别名导出)      }  // 结束该分支块    }  // 结束该分支块    return claims  // 返回完整 SRC endpoint 集合  }  // 结束该分支块

为什么这样设计:认领逻辑是"白名单制"——网关只接自己声明过的 endpoint,其余一律不碰。这带来两个好处:多个网关可以共存于同一个 /api 前缀下互不干扰;未导出的内部方法天然不可达(不是靠权限检查挡住,而是根本不在路由表里)。srcClaims 的惰性缓存 + internal/service 事件失效,保证插件热挂载/卸载后认领集合自动跟上——这正是 AGENTS.md 里"registrations are effects"原则在网关侧的体现。

还有一个容易忽略的细节:claimsEndpoint 对 strict endpoint 的判断用了 get() !== undefined || hasSeen()——已撤回的 strict 定义也归网关认领。为什么?因为如果撤回后不认领,这个 endpoint 会掉进别的处理路径(或 SRC 兜底),行为就变了;正确语义是"我认领、但我明确拒绝执行",由 resolveDescriptor 报出 definition-unavailable。

五、invoke():一次调用的主流程

invoke() 是网关的心脏。它把 wire 请求变成业务调用,顺序固定:解析描述符 → 对账参数 → 解析接收者 → 解析每个参数 → 反射执行 → 校验返回值。任何一步失败都抛出带稳定 code 的 TypertGatewayError:

📄 packages/api/gateway/src/index.ts (第 145-184 行)

async invoke(request: InvokeRemoteRequest): Promise<unknown> {  // 一次 Remote 调用的总入口:从 wire args 到业务返回值  const endpoint = endpointOf(request.namespace, request.method)  // 拼规范 endpoint,后续所有错误信息都用它定位  const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)  // 解析调用描述符:strict 定义优先,SRC marker 兜底  assertExactArguments(request.args, descriptor, endpoint)  // wire args 与描述符逐字段对账:多一个少一个都拒绝  const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)  // context 模式:先按 identity 解析出目标 Context(接收者)  const receiver = receiverContext.get(descriptor.service) as unknown  // 在目标 Context 里取 Service 实例  if (!isObject(receiver)) {  // 服务不在该 Context 上(未激活/被卸载)    throw new TypertGatewayError('service-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} is unavailable`)  // 明确报 service-unavailable,不静默降级  }  // 结束该分支块  validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)  // 复核 typertRemote 绑定与描述符一致——防"张冠李戴"的 Service  const args = await Promise.all(descriptor.parameters.map(parameter =>  // 并行解析全部业务参数(json 解码 / lookup 换实体)    this.resolveParameter(parameter, request.args, endpoint)))  // 任一失败即整体拒绝,不会半执行  if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)  // 声明了取消的:signal 追加为最后一个实参,不进 wire args  const implementation = descriptor.implementation ?? descriptor.method  // 别名导出时调实现成员,否则调同名方法  const method = Reflect.get(receiver, implementation) as unknown  // 反射取方法  if (typeof method !== 'function') {  // 方法不存在或不可调用    throw new TypertGatewayError('method-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`)  // 报 method-unavailable,区分"服务在但方法没了"  }  // 结束该分支块  let result: unknown  // 业务返回值暂存  try {  // 包住真正的业务调用,好把取消与业务失败分开处理    result = await Reflect.apply(method, receiver, args) as unknown  // 真正执行业务方法;receiver 绑定 this  } catch (error) {  // 执行抛错    if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)  // carrier 已取消:转成 cancelled 语义,不把业务错误当取消报    throw error  // 其余原样上抛——业务错误的身份必须保留给 rpcFailure 分类  }  // 结束该分支块  if (result === undefined && descriptor.result.mode !== 'strict') return result  // SRC(弱描述符)没声明返回类型:undefined 就是 void,直接放行  return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')  // strict 模式:返回值也要过 schema——出边界前最后一道校验}  // 结束该分支块

为什么这样设计:三个点。其一,参数解析全部前置且并行(Promise.all):要么所有参数都成功、业务方法才执行,要么一个都不执行——杜绝"第一个 lookup 成功、第二个失败,但业务方法已经改了状态"的半执行窗口。其二,取消语义的精确区分:只有 carrier signal 真的 aborted 才转成 RemoteInvocationCancelled;业务方法自己抛的错保持原身份上抛——因为客户端需要区分"我取消了"和"它失败了",两者的重试策略完全不同。其三,返回值也过边界校验:strict 模式下业务方法返回的对象必须能通过 schema.parse 且 JSON-safe,否则报 result-invalid——Host 侧的 bug 不会变成 wire 上的脏数据。

描述符解析是双轨仲裁的核心,尤其"撤回后禁止兜底"这条规则:

📄 packages/api/gateway/src/index.ts (第 224-235 行)

private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {  // 描述符解析:strict 与 SRC 的仲裁点  const strict = this.ctx.typert.local.get(endpoint)  // 先查 strict 生成定义(registry local store)  if (strict !== undefined) return strict  // 在册:直接用,SRC 路径根本不参与  if (this.ctx.typert.local.hasSeen(endpoint)) {  // 该 endpoint 曾经注册过 strict 定义、后来被撤回    throw new TypertGatewayError('definition-unavailable', endpoint, 'its strict definition was withdrawn and SRC fallback is forbidden')  // 禁止降级到 SRC:防止"半严格"状态——要么全 strict,要么明确不可用  }  // 结束该分支块  return this.resolveSrcDescriptor(namespace, method, endpoint)  // 从未见过 strict 定义:走 SRC marker 推导(弱描述符)}  // 结束该分支块

为什么这样设计:hasSeen 是 registry 里一个只增不减的历史集合(DescriptorStore.history)。它堵住了一个微妙的安全洞:如果 strict 定义被撤回后允许 SRC 兜底,那么同一个 endpoint 在"有 artifact"和"没 artifact"两种装配下会走不同的校验强度——strict 轨要求 schema.parse + 精确的可选性,SRC 轨只查 JSON-safe。攻击面(或 bug)就可以通过切换装配来绕过严格校验。规则因此是二元的:见过 strict 就永远按 strict 对待,撤回 = 不可用,而不是降级。

六、参数解析:lookup 把 id 换回实体

回到 edit(agent, ref, request) 的例子:agent 在 wire 上是一个 id 字符串。网关通过 registry 里的 lookup provider 把它换回活对象——这是"wire 传身份、Host 给实体"的依赖反转:

📄 packages/api/gateway/src/index.ts (第 407-468 行,节选)

private async resolveParameter(parameter: InvocationParameterDescriptor, args: Readonly<Record<string, unknown>>, endpoint: string): Promise<unknown> {  // 解析单个业务参数:wire 值 → Host 实体  if (!Object.hasOwn(args, parameter.wire)) return undefined  // 字段缺席:只有声明了 T|undefined 的 json 参数能走到这(assertExactArguments 已放行);lookup id 永不缺席  const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)  // strict codec 先 schema.parse,再断言 JSON-safe——入边界第一道门  if (parameter.source === 'json') return value  // json 参数:解码后直接交给业务方法  const key = parameter.lookup  // lookup 参数的 provider key(strict 必有;SRC 推导时已保证)  if (key === undefined) {  // 防御分支:理论上到不了,到了就是描述符坏了    throw new TypertGatewayError('lookup-unavailable', endpoint, `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, { field: parameter.wire })  // 报 lookup-unavailable 而不是 TypeError——错误分类稳定可机读  }  // 结束该分支块  const provider = this.ctx.typert.lookups.get(key)  // 从 registry 取 lookup provider(wire id → Host 对象的解析器)  if (provider === undefined) {  // provider 未注册    throw new TypertGatewayError('lookup-unavailable', endpoint, `lookup provider ${JSON.stringify(key)} is unavailable`, { field: parameter.wire })  // 明确报"provider 不在",调用方知道该去装配而不是重试  }  // 结束该分支块  if (provider.wire !== parameter.wire  // provider 的 wire 字段必须与描述符一致    || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {  // strict 模式还要类型符号对齐——防"同名不同型"的 provider 顶包    throw new TypertGatewayError('provider-mismatch', endpoint, `lookup provider ${JSON.stringify(key)} does not match its strict definition`, { field: parameter.wire })  // 报 provider-mismatch:装配错误,不是数据错误  }  // 结束该分支块  let resolved: unknown  // 解析出的 Host 实体  try {  // 包住 provider.resolve,好区分策略拒绝与 provider 崩溃    resolved = await provider.resolve(value)  // 调 provider:wire id → 活对象(如 agent id → Agent)  } catch (cause) {  // provider 抛错    if (cause instanceof TypertLookupFailure) throw cause  // 策略性拒绝(权限/不存在)保留原身份——它的 typed payload 要原样回给调用方    throw new TypertGatewayError('lookup-failed', endpoint, `lookup provider ${JSON.stringify(key)} failed`, { cause, field: parameter.wire })  // 其余归为 lookup-failed,cause 挂上供诊断  }  // 结束该分支块  if (resolved === undefined) {  // provider 返回 undefined = "查无此 id"    throw new TypertGatewayError('lookup-not-found', endpoint, `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, { field: parameter.wire })  // lookup-not-found:与 failed(provider 崩了)区分开  }  // 结束该分支块  return resolved  // 返回活对象——业务方法拿到的是真实体,不是 id}  // 结束该分支块

为什么这样设计:lookup 的错误被拆成三个 code(unavailable / failed / not-found),对应三种完全不同的处置:装配缺失要改配置、provider 崩溃要看日志、id 不存在是调用方传错了。如果都折叠成一个 "lookup error",客户端就没法自动区分"重试有用"还是"重试没用"。

TypertLookupFailure 的特殊地位:它是 protocol 层定义的"策略性拒绝"包装——provider 可以抛出 new TypertLookupFailure({ code: 'FORBIDDEN', message: ... }),网关识别后原样透传其 typed payload(rpcFailure 里直接返回 error.failure),而不是折叠成 internal。这是"业务边界错误保留身份、基础设施错误统一归类"的分界线。

context 模式的接收者解析(resolveReceiverContext)走的是同构逻辑:从 wire identity 经 contexts.getHost(key).resolve(identity) 拿到目标 Cordis Context,再在该 Context 里取 Service。区别只在于它解析的是"在哪个作用域执行",而 lookup 解析的是"参数值是什么实体"。

七、wire 边界安全:JSON-safe 断言与 envelope

即使 strict schema 通过了,值仍可能"合法但不可序列化"——循环引用、稀疏数组、class 实例。网关用 assertJsonValue 做最后一道递归断言:

📄 packages/api/gateway/src/index.ts (第 640-673 行)

function assertJsonValue(value: unknown, ancestors: Set<object>): void {  // 递归断言一个值"JSON-safe":能安全过 wire 的充分条件  if (value === null || typeof value === 'string' || typeof value === 'boolean') return  // 标量叶子直接放行  if (typeof value === 'number') {  // number 要单独审    if (Number.isFinite(value)) return  // 有限数 OK    throw new TypeError('non-finite number is not JSON-safe')  // NaN/Infinity 序列化后变 null——静默数据丢失,必须拒绝  }  // 结束该分支块  if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)  // function/symbol/bigint:JSON 表示不了,拒  if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')  // 祖先链里见过 = 循环引用,JSON.stringify 会炸  ancestors.add(value)  // 入栈(递归深度标记)  try {  // 审完子节点后在 finally 里出栈,保证兄弟分支可复用同一对象    if (Array.isArray(value)) {  // 数组分支      if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {  // 带 symbol 属性或"键数≠长度"= 稀疏/装饰数组        throw new TypeError('sparse or decorated array is not JSON-safe')  // 序列化会丢东西,拒      }  // 结束该分支块      for (let index = 0; index < value.length; index += 1) {  // 逐槽检查        if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')  // 空洞数组:JSON.stringify 输出 null,拒        assertJsonValue(value[index], ancestors)  // 递归审元素      }  // 结束该分支块      return  // 数组审完即返回    }  // 结束该分支块    if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')  // class 实例/Date/Map:JSON 表示会变形,拒——wire 上只认 plain object    if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')  // symbol 键序列化直接消失,拒    for (const key of Reflect.ownKeys(value)) {  // 逐属性审      const descriptor = Object.getOwnPropertyDescriptor(value, key)  // 取属性描述符      if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {  // getter/setter/不可枚举:JSON.stringify 行为不一致,拒        throw new TypeError('non-data property is not JSON-safe')  // wire 边界只接受纯数据属性      }  // 结束该分支块      assertJsonValue(descriptor.value, ancestors)  // 递归审值    }  // 结束该分支块  } finally {  // 无论审查成功还是抛错都要出栈,否则祖先链残留会把后续兄弟误判为环    ancestors.delete(value)  // 出栈:兄弟分支可以合法复用同一对象(DAG),只有真循环才该被拒  }  // 结束该分支块}  // 结束该分支块

为什么这样设计:这段代码的本质是回答"JSON.stringify 会不会悄悄改变我的数据"。它拒绝的不是"非法值",而是序列化后语义会漂移的值:NaN→null、稀疏数组→[null]、class 实例→丢失方法、循环引用→直接抛异常。ancestors 用 try/finally 进出栈而不是全局 Set,是为了允许 DAG(同一对象出现在两个兄弟位置)——只有真正的环才拒绝。这种"白名单式序列化安全"比"黑名单禁某些类型"可靠得多。

调用的最终出口是 envelope:成功与失败都走同一个 

{ ok, value | error } 形状,错误在这里被折叠成封闭的 code:

📄 packages/api/gateway/src/index.ts (第 215-221、471-489 行)

return { ok: true, value }  // void/缺席结果没有 value 字段——JSON 没有 undefined,可选槽位就是"缺席"的唯一表示function rpcFailure(error: unknown): ConnectionRpcResult {  // 把任意异常折叠成 wire 错误面  if (error instanceof RemoteInvocationCancelled) {  // carrier 取消:独立语义    return { ok: false, error: { code: 'cancelled', message: error.message, details: {} } }  // code='cancelled',调用方可据此区分"被取消"与"业务失败"  }  // 结束该分支块  if (error instanceof TypertLookupFailure) {  // lookup 策略拒绝:typed payload 必须原样透传    return { ok: false, error: error.failure as ConnectionRpcError }  // failure 是边界适配器拥有的结构化错误,不折叠成 internal  }  // 结束该分支块  return {  // 其余一切:兜底 internal    ok: false,  // 失败分支标记——与成功分支的 ok:true 互斥    error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },  // 保留原始 message 供诊断,但不暴露堆栈/内部结构  }  // 结束该分支块}  // 结束该分支块

为什么这样设计:rpcFailure 是"错误身份保留策略"的总闸:三类错误三种待遇——取消(客户端主动)、lookup 拒绝(业务边界、payload 透传)、其余(internal 兜底)。注意 internal 分支只带 message 不带 stack trace:wire 上的错误面是给调用方看的,不是给 Host 运维看的,堆栈留在服务端日志里。这与前面 TypertGatewayError 构造器注释"without embedding boundary values in its message"是同一原则:边界值(id、token)不进诊断文本。

错误码
触发场景
调用方处置
definition-unavailable
strict 定义已撤回,SRC 兜底被禁止
检查装配:该包是否应导出 ./typert
invocation-unavailable
没有活跃 Remote method 导出该 endpoint
检查包是否挂载 / 方法是否存在
ambiguous-endpoint
多个 Service 导出同一 endpoint
去重:改 namespace 或下线一个
service-unavailable
 / method-unavailable
Service 不在目标 Context / 方法不可调用
检查组合(composition)是否完整
arguments-invalid
args 字段与描述符不匹配(missing / unexpected)
修 wire args;错误信息列出具体字段
input-invalid
 / result-invalid
边界 schema 校验失败(入参 / 返回值)
修数据形状;result-invalid 说明 Host 有 bug
lookup-unavailable
 / lookup-failed / lookup-not-found
provider 未注册 / resolve 抛错 / id 查无
分别对应:改装配、看日志、修调用参数
context-unavailable
 / context-failed / context-not-found
Context provider 未注册 / resolve 抛错 / identity 查无
同 lookup 三态,作用域换成 Context
provider-mismatch
provider 的 wire / typeSymbol 与描述符不一致
修 provider 声明——装配错误,重试无用
binding-invalid
 / signature-invalid
typertRemote 绑定缺失或不一致 / SRC 方法签名非法(解构、默认值、rest)
检查基类用法;改简单标识符参数
cancelled
carrier signal aborted,业务调用被取消
客户端主动行为,可安全重试

这张表把前面各节散落的错误码收拢成一张处置对照——每个 code 都对应"调用方能做什么",这正是封闭错误集合的价值:客户端可以写 switch (error.code) 而不是正则匹配报错文本。

八、typert-loader:自动发现与增量注册

strict 轨的定义从哪来?@deepseek-ai/dsh-typert-loader 插件负责:每个挂载的 loader entry,如果其包导出了 ./typert artifact,就动态 import、校验 manifest、注册进 registry;entry 卸载时自动撤回。核心是"增量脏集 + 微任务批量对账":

📄 packages/typert/loader/src/index.ts (第 315-341 行)

const resolveArtifact = (pkgName: string): string | null => {  // 包名 → ./typert artifact 路径(带缓存)  const cached = artifactPath.get(pkgName)  // 命中缓存直接返回——负判定也缓存,永不重试  if (cached !== undefined) return cached  // 插件集变更靠重启生效,运行期不追  let pkgPath: string  // package.json 的绝对路径  try {  // 解析包根;loader builtin(cordis:include)/子路径会在这里失败    pkgPath = require.resolve(`${pkgName}/package.json`)  // 解析包根;loader builtin(cordis:include)/子路径会在这里失败  } catch (cause) {  // 解析不到    if (configured.has(pkgName)) {  // 但它是用户显式配置的包      throw new Error(`typert-loader: configured package "${pkgName}" cannot be resolved from the config tree — add it to the composition package dependencies or remove it from packages`)  // 显式配置必须响亮失败,错误信息直接给出修法    }  // 结束该分支块    artifactPath.set(pkgName, null)  // 隐式发现的:记负判定,永久跳过    return null  // 不是 typert contributor  }  // 结束该分支块  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as Record<string, unknown>  // 读 package.json  const rel = typertExportOf(pkgName, pkg.exports)  // 取 exports["./typert"](支持字符串和一层条件对象两种形态)  if (rel === undefined && configured.has(pkgName)) {  // 显式配置却没导出 ./typert    throw new Error(`typert-loader: configured package "${pkgName}" does not export "./typert"`)  // fail loud:配置了就必须有 artifact  }  // 结束该分支块  const resolved = rel === undefined ? null : join(dirname(pkgPath), rel)  // 拼出 artifact 绝对路径  artifactPath.set(pkgName, resolved)  // 缓存(含负判定)  return resolved  // 交给 loadManifest 动态 import + 校验}  // 结束该分支块

为什么这样设计:"显式配置 fail loud、隐式发现静默跳过"是 loader 的核心策略。用户把包写进 packages 配置,就是明确声明"这个包必须贡献 typert artifact"——解析不到或没导出都是装配错误,必须炸;而自动发现的 entry(比如一个普通 UI 插件)没有 artifact 是完全正常的,静默跳过即可。负判定永久缓存则避免了每次脏集对账都重复 require.resolve + 读盘——代价是运行期新增的包要重启才生效,这在"插件集 = 部署单元"的前提下是可接受的。

📄 packages/typert/loader/src/index.ts (第 411-436 行)

ctx.on('internal/plugin', (fiber) => {  // 监听插件挂载事件:每个 entry 的 fiber 激活都算一次"脏"  const entryName = fiber.entry?.options.name  // 取 loader entry 名;无 entry 的 fiber(子插件/手动挂载)不是 loader 行,O(1) 丢弃  if (entryName === undefined) return  // 直接返回  dirty.add(entryName)  // 标记脏——Set 幂等,重复事件无害  if (flushQueued) return  // 已有微任务在排队:不重复排  flushQueued = true  // 置位防抖标志  queueMicrotask(() => {  // 微任务批量处理:同一 tick 内多个挂载只触发一次扫描    flushQueued = false  // 清标志    if (!active) return  // 插件已卸载:不再工作    for (const task of flush((err) => { ctx.logger.error(err) })) void task  // 逐个 entry 对账;稳态下单包损坏只记日志,不毒化其他包  })})for (const packageName of configured) dirty.add(packageName)  // 激活路径:显式配置的包先入脏集for (const entry of ctx.loader.entries()) dirty.add(entry.options.name)  // 再灌入当前全部 loader entries——初始扫描就是增量路径的特例const failures: Error[] = []  // 收集激活期失败await Promise.all(flush((err) => { failures.push(err) }))  // 并发处理所有脏 entry,等齐if (failures.length > 0) {  // 有失败  throw new AggregateError(  // 聚合成一个错误抛出——loader fiber FAILED,boot sweep 会报告    failures,  // 把每个失败包的原因都带上,一次看全而不是只看到第一个    `typert-loader: ${String(failures.length)} typert contributor(s) failed to register:\n${failures.map(e => `  - ${e.message}`).join('\n')}`,  // 逐条列出:哪个包、什么错,一次看全  )}  // 结束该分支块

为什么这样设计:两个阶段两种错误策略:激活期聚合抛出(AggregateError,一个坏包让 boot 失败——因为此时"部分注册"比"全部不注册"更危险),稳态期逐包容错(记日志、继续处理其他 entry——运行中一个插件损坏不该拖垮整个网关)。微任务防抖则解决了事件风暴问题:一次启动挂载几十个插件,只触发一轮对账而不是几十轮。而 processOne 里 import 完成后的二次检查(if (!active || !qualifies(entryName) || registered.has(entryName)) return)堵住了"import 在途时 entry 已卸载"的竞态——注册动作永远以落库时刻的状态为准。

manifest 校验的边界感:validateTypertManifest(loader/src/index.ts 第 83-276 行)逐字段检查 TYPERT 导出——包名必须与导出方一致、face 必须是 'host'、每个 codec 必须有 zod v4 schema。注释说得很直白:"the manifest crosses from a build artifact into the typed registry, so every field is checked and every failure names the package and the defect"。构建产物是信任边界:它来自另一个包的编译输出,不能假设形状正确。

九、全链路数据流:一次 goals/edit 调用

调用路径(Client → Host)

① Client 调用:goals.edit(agentId, ref, request)

carrier 序列化为 /api/goals/edit + 命名 args 对象;agent 参数在 wire 上只是 id。

▼

② endpoint 认领:claimsEndpoint

strict registry 命中或 SRC marker 命中才接;已撤回的 strict 也认领(为拒绝而认领)。

▼

③ 描述符解析:resolveDescriptor

strict 优先;hasSeen 命中则禁止 SRC 兜底,报 definition-unavailable。

▼

④ args 对账:assertExactArguments

missing / unexpected 字段都拒绝;错误信息列出具体字段名,不半执行。

▼

⑤ 接收者与参数解析:resolveReceiverContext / resolveParameter

🔹 context 模式:provider.resolve(identity) → 目标 Context,再取 Service🔹 lookup 参数:provider.resolve(id) → Host 活实体;TypertLookupFailure 原样透传

▼

⑥ 执行与返回:Reflect.apply → decode → envelope

strict 返回值过 schema + JSON-safe;成功 {ok:true,value},失败折叠为封闭 code。

本讲回顾:Typert 网关把"跨进程调用一个类型化方法"拆成了六个可独立失败的阶段,每个阶段有稳定的错误码、明确的失败语义。声明侧(typertRemote + @Remote)只写私有数据;发现侧(claims / SRC scan)只读数据;执行侧(invoke)在边界上双向校验。strict/SRC 双轨不是"新旧兼容",而是"有生成产物就严格、没有就保守、撤回过就拒绝"的三态仲裁——这套设计让网关在插件热插拔、HMR、部分装配的场景下都能给出确定行为。

下一讲我们进入 API 网关与 Remote 服务(第 34 讲),看 @deepseek-ai/dsh-api-remotes 如何在客户端侧把这套协议组装成可调用的命名空间 API。

📚 系列导航

← 第 32 讲:Credentials 密钥与凭证管理

→ 第 34 讲:API 网关与 Remote 服务

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料