Remote API 网关
一句话版:Typert Remote 网关是 Web UI 的双向 RPC 端点:宿主侧
ctx.typertGateway按生成的InvocationDescriptor校验具名参数、解析 agent/session 身份并注入取消信号;客户端@deepseek-ai/dsh-api-gateway/client提供ctx.remote($mount()/$on()/$dispatch());插件作者用@Remote/@RemoteScope装饰器把宿主能力暴露给远端,api-remotes 封装 Agent 身份策略与转发事件名单。它演进自 legacy apiproxy,两者共享连接层。
一、两代网关:从 apiproxy 到 Typert Gateway
packages/api/gateway 是"宿主 + 客户端"两侧的 Typert RPC endpoint 包:
- Host 入口(
packages/api/gateway/src/index.ts)注册ctx.typertGateway @deepseek-ai/dsh-api-gateway/client(packages/api/gateway/src/client/index.ts)注册ctx.remote- 消费同一份生成的
InvocationDescriptor约定;传输、请求关联、信任与响应封装交给 Connection
与 apiproxy 的关系:packages/host/apiproxy(包名 @deepseek-ai/dsh-host-apiproxy)是每个 client 共享的旧网关,它消费 ctx.apiProxy、{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?} 配置、注册在 Web 组合 id api-gateway(见 Web UI 架构)。新 Gateway 在 Connection 共享的 /api FetchHandler 上注册 trusted-host interceptor,把"已认领 endpoint 分发给 Gateway、未认领 endpoint 交给 API Proxy"——所以已迁移与未迁移方法并行共存,api-remotes 的 resolver 同时被 legacy 方法与 Typert lookup 使用(见下)。
二、Host 服务:ctx.typertGateway.invoke()
TypertGatewayService(packages/api/gateway/src/index.ts)每次 invoke() 做五件事:
| 步骤 | 源码动作 |
|---|---|
| 解析描述符 | resolveDescriptor:先查严格定义(ctx.typert.local),无则 SRC 回退;已观测定义被撤回时直接报错,不降级 |
| 校验具名参数 | assertExactArguments:args 必须与描述符 wire 字段完全匹配(缺 + 多都抛 arguments-invalid) |
| 解析 receiver | resolveReceiverContext:direct 用 ctx 根;context 走注册的 Host Context provider 解析身份 |
| 校验绑定 | validateBinding:服务必须带一致的 typertRemote binding,否则 binding-invalid |
| 调用并校验结果 | Reflect.apply 方法,经 codec 边界 decode 校验返回值 |
// packages/api/gateway/src/index.ts —— 取消信号是 descriptor 元数据,不是 wire 参数
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
支持取消的 Remote 方法把 signal: AbortSignal 声明为最后一个 Host 参数——它由 Connection 提供给 Gateway,在解码后的业务参数之后注入,而非来自 wire。
错误分类
TypertGatewayError 携带稳定的机器码分类:
| code | 含义 | 触发场景 |
|---|---|---|
arguments-invalid | 参数与描述符不匹配 | 缺字段 / 多余字段 |
lookup-not-found / lookup-failed / lookup-unavailable | lookup 解析失败 | identity 解析不到 / provider 出错 / 无 key |
context-not-found / context-failed / context-unavailable | @RemoteScope 接收者解析失败 | Host Context provider 未注册或无法解析 |
binding-invalid / service-unavailable / method-unavailable | 绑定或服务缺失 | 无 typertRemote binding / 无活跃 Service / 无可调用方法 |
input-invalid / result-invalid | 边界校验失败 | wire 字段或返回值不通过 codec |
ambiguous-endpoint / signature-invalid / definition-unavailable | SRC 推导失败 | 多服务导出同端点 / SRC 签名非法 / 定义被撤回 |
resolver 可用 TypertLookupFailure 携带既有 RPC 错误,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码(不压成 internal)。直接调用 invoke() 保留业务错误;经 RPC 适配器时,普通分发故障与业务异常映射到 internal,TypertLookupFailure 原样返回。
SRC 模式
SRC(strict-required 的开发回退)用于从未严谨定义过的端点:从 JS 方法签名解析简单参数名(要求唯一标识符参数,不支持解构/默认值/rest),只允许非 lookup 参数是 JSON 安全值,不推断可选字段。SRC 的 cancellation 参数必须是末位。
三、插件作者:@Remote / @RemoteScope 装饰器
装饰器生活在 @deepseek-ai/dsh-typert-protocol(packages/typert/protocol/src/index.ts)。业务服务继承 TypertRemoteService,用装饰器标记方法:
// packages/typert/protocol/src/index.ts —— 两种标记
export function Remote(exportName?: string): RemoteMethodDecorator // 直接 Remote
export function RemoteScope(
key: Extract<keyof TypertContextMap, string>, exportName?: string,
): RemoteMethodDecorator // 按 Scope 解析 receiver
export abstract class TypertRemoteService extends Service {
readonly typertRemote: TypertGatewayBinding<this> // 绑定服务 key 与 wire namespace
}
@Remote把一个公开实例方法标记为直接 Remote 调用(endpoint =<namespace>/<method>)@RemoteScope(key)让方法被key对应的 Host Context provider 解析 receiver——每个调用针对某个具体 session/agent 的 scope 上下文- 已有其他基类无法继承
TypertRemoteService时,用bindTypertRemote(service, serviceKey, options)显式绑定 @RemoteScope在 SRC 推导中还对应 wire 上的 context 身份字段(invocation.kind === 'context')
四、Client 服务:ctx.remote
ClientRemoteService(packages/api/gateway/src/client/index.ts)把生成的 Host-for-Client 贡献挂载为可调用方法:
| API | 作用 |
|---|---|
$mount(contribution) | 校验并注册生成的贡献项,为调用 fiber 安装直接/作用域方法;每个 namespace 是可追踪的 remote.<namespace> 子 Service,最后一个方法撤回后卸载 |
$on(event, listener) | 订阅一条被转发的 Host 事件;合法键恰好是 Host 装配的转发选择;listener 属于调用 fiber,随 fiber 消失 |
$dispatch(event, args) | 属于载体的另一半:持有 Host 帧 sink 的 Client 半把解码后的帧交进来;无人订阅的事件名丢弃 |
| 方法调用 | 校验位置参数 → 构造精确具名 args → ctx.connection.rpc.call('/api', endpoint, {args}) |
// packages/api/gateway/src/client/index.ts —— 取消合并贡献挂载生命周期与调用者 signal
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
- 无 JavaScript Proxy:方法查找与调用用普通对象与函数(
getter闭包绑定调用时 fiber 的 ctx) - 撤回语义:撤回贡献会移除其描述符与方法、中止 in-flight 调用;仍被外部持有的方法句柄在调用时返回
withdrawn拒绝 - 每个订阅按注册顺序投递,抛错的 listener 被隔离(logged),不影响其余 listener 的帧泵
- Client 只挂载严格生成的贡献项;SRC 标记没有 Client codec/类型投影
- 只分发一元方法;增量 session 数据走同一个 Connection 上的独立具名流协议
五、api-remotes:应用侧的 BFF 与身份策略
packages/api/remotes(包名 @deepseek-ai/dsh-api-remotes)是应用选定的 Host Remote 能力的双侧 BFF:Host 入口持有 Agent/Session 身份策略,Client 入口把生成的 /remote 产物以运行时值导入、$mount() 挂载并重导出声明合并。Client 业务包依赖此外观,不依赖 Gateway 实现或单独 Remote 运行时入口。
createApiRemoteAgentResolver:live 复用 / 冷恢复 / ownership fence
packages/api/remotes/src/agent-lookup.ts 的 createApiRemoteAgentResolver() 做四件事:
| 策略 | 行为 |
|---|---|
| 复用 live Agent | fencedLiveAgent:ctx.agents.get(sessionId) 命中直接用(除非被 fence) |
| 普通冷会话恢复 | 无 live 时经 persistence.inspect 读冷 session,再 ctx.agents.resume |
| 并发去重 | resumes Map 对同一 identity 共享同一个 resume Promise,完成后删除 |
| subagent ownership fence | hasApiRemoteSubagentOwner:origin 为 subagent 或挂在父 subagent 下的 session 拒绝通用路由 |
// packages/api/remotes/src/agent-lookup.ts —— fence 判定
export function hasApiRemoteSubagentOwner(ctx, session, agent): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
被 fence 的 identity 返回原 agent-busy RPC 形状(apiRemoteSubagentOwnershipError),提示"这是子 Agent 会话,用 subagent delivery"。resolver 同时配置 Typert 的 agent 与 session lookup,以及 contexts.configureHost('agent', ...),因此迁移与未迁移方法共用同一份身份策略实现。options.setup 在冷恢复时按被恢复的 session 组装 Agent-scope 组合(以 session 自身 keyed,因为 agent preset 固定了其历史产生的工具集合)。
转发事件白名单:API_REMOTE_FORWARDED_EVENTS
packages/api/remotes/src/remote-events.ts 是唯一控制点——它同时决定 Host 转发循环与 ctx.remote.$on 的合法键集:
// packages/api/remotes/src/remote-events.ts —— 原样转发:无投影、无脱敏、无改名
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'llm/adapters-updated',
'settings/document-updated',
] as const
- 多转发一个事件 = 往数组加一行;类型投影、消费端键面、Host 转发循环全部由它派生
- Host face 额外断言该数组满足
TypertForwardableEvent:未声明的事件名、绑定 AgentScope 的事件、非单向(wave 非 void)的事件都会被编译器拒绝 - listener 签名来自各 owner 包 client-safe 的
./types出口(dsh-agent-presets/dsh-commands/dsh-credentials/dsh-llm/dsh-settings),"原样转发"是构造性成立的 - 事件原样到达
$on:无载荷投影/脱敏,无 Scope 订阅,重连不重放
构建边界
api-remotes 是仓库里唯一刻意跨 Host/Client 两 face 的包:根 tsconfig.json 只是引用 tsconfig.host.json + tsconfig.client.json 的 solution。src/remote-events.ts 与 src/types.ts 同时列入两个 face 的 files,确保转发名单只有一份声明;tsconfig.base.json 把 @deepseek-ai/dsh-api-remotes/types 映射到源平面。
六、Client 组合:挂载了哪些贡献
packages/api/remotes/src/client/index.ts 显式导入并挂载四个 remote 贡献:
commandsRemote · goalsRemote · pluginInventoryRemote · messageFeedbackRemote
插件库存贡献由 @deepseek-ai/dsh-host-plugin-inventory 提供(见 插件库存)。能力集由构建时显式值导入固定;Client 不在运行时发现 Host 的活跃 Service 或 Remote 定义。
验证
# 在 Web 组合树里确认网关 + remotes 相关的行
dsh web --dump-config | grep -iE "api-gateway|api-remotes|apiproxy|plugin-inventory"
# 看 host 进程监听的端口
lsof -nP -iTCP:3080 -sTCP:LISTEN
# 在浏览器设置「插件」标签页可看到插件库存(经 pluginInventory/list)