feat(connection): dispatch TypeRT remotes through shared API

This commit is contained in:
imccyu
2026-08-06 16:41:24 +08:00
parent 4eff751058
commit 36516e97b9
20 changed files with 439 additions and 182 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md
README.md: 3ef926ace2ee4d6008b1d6c18b1e070fa39bc176
README.zh.md: 77b8b8a87d5f511000aac5cf9f75ebca5fcdfbca
README.md: cc80bb19fec15414aa0857154a8a36fb4f642672
README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295

View File

@@ -10,13 +10,13 @@ Two-sided Remote control for Host and Client Cordis environments. The Host entry
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use registered `ctx.typert.lookups` providers, while `@RemoteContext` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
The Host entry registers the trusted-host `/api2` unary RPC channel when Connection is available. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
## Client service: `ClientApi` (ctx key: `api`)
`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api2', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.

View File

@@ -10,13 +10,13 @@
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用已向 `ctx.typert.lookups` 注册的提供方,`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
Connection 可用时,Host 入口会注册 trusted-host 的 `/api2` 一元 RPC 通道。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
## Client 服务:`ClientApi`(ctx key:`api`)
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api2', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。

View File

@@ -248,7 +248,7 @@ class ClientApiService extends Service implements ClientApi {
})
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
const result = await connection.rpc.call('/api2', endpoint, { args }, token.abort.signal)
const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal)
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
if (!result.ok) throw remoteFailure(endpoint, result.error)
return parse(descriptor.result, result.value, endpoint, 'result')

View File

@@ -5,6 +5,7 @@
*/
import { Context, Service, symbols } from 'cordis'
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
import {
remoteMethods,
type InvocationDescriptor,
@@ -35,26 +36,7 @@ interface ResolvedBinding {
readonly original: object
}
type ConnectionRpcResult =
| { readonly ok: true; readonly value: unknown }
| {
readonly ok: false
readonly error: {
readonly code: 'internal'
readonly message: string
readonly details: Record<never, never>
}
}
interface HostConnectionLike {
readonly rpc: {
handle(
channel: string,
handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<ConnectionRpcResult>,
options: { readonly authority: 'trusted-host' | 'loopback' },
): () => Promise<void>
}
}
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
/** Dispatch failure produced outside the invoked business method. */
export class TypertGatewayError extends Error {
@@ -101,15 +83,32 @@ export class TypertGatewayService extends Service implements TypertGateway {
constructor(ctx: Context) {
super(ctx, 'typertGateway')
ctx.inject(['connection'], (connectionCtx) => {
const connection = connectionCtx.get('connection') as unknown as HostConnectionLike
connection.rpc.handle(
'/api2',
connectionCtx.connection.rpc.intercept(
'/api',
endpoint => this.claimsEndpoint(endpoint),
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
{ authority: 'trusted-host' },
)
})
}
private claimsEndpoint(endpoint: string): boolean {
const segments = endpoint.split('/')
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
const [namespace, method] = segments as [string, string]
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
if (definition.type !== 'service') continue
const receiver = this.ctx.get(serviceKey) as unknown
if (!isObject(receiver)) continue
const original = originalOf(receiver)
const binding = Reflect.get(original, 'typertGateway') as unknown
if (!isObject(binding) || Reflect.get(binding, 'namespace') !== namespace) continue
if (remoteMethods(original).some(candidate => (candidate.exportName ?? candidate.method) === method)) return true
}
return false
}
/**
* Invoke one live Remote method through strict generated reflection or SRC markers.
* @param request - decoded endpoint and exact named wire arguments.

View File

@@ -109,7 +109,7 @@ describe('Client TypeRT API', () => {
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
expect(call).toHaveBeenCalledWith(
'/api2',
'/api',
'goals/create',
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
expect.any(AbortSignal),
@@ -144,7 +144,7 @@ describe('Client TypeRT API', () => {
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
expect(call).toHaveBeenCalledWith(
'/api2',
'/api',
'goals/create',
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
expect.any(AbortSignal),
@@ -175,7 +175,7 @@ describe('Client TypeRT API', () => {
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenCalledWith(
'/api2',
'/api',
'goals/rename',
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
expect.any(AbortSignal),

View File

@@ -96,6 +96,7 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal)
class FakeConnectionService extends Service {
channel: string | undefined
authority: string | undefined
matches: ((endpoint: string) => boolean) | undefined
handler: FakeRpcHandler | undefined
constructor(ctx: Context) {
@@ -105,14 +106,21 @@ class FakeConnectionService extends Service {
get rpc() {
const owner = this.ctx
return {
handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) =>
intercept: (
channel: string,
matches: (endpoint: string) => boolean,
handler: FakeRpcHandler,
options: { readonly authority: string },
) =>
owner.effect(() => {
this.channel = channel
this.authority = options.authority
this.matches = matches
this.handler = handler
return () => {
this.channel = undefined
this.authority = undefined
this.matches = undefined
this.handler = undefined
}
}),
@@ -820,7 +828,7 @@ describe('TypertGatewayService', () => {
}), 'invocation-unavailable')
})
it('mounts /api2 through an optional Connection and returns existing RPC results', async () => {
it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => {
const ctx = new Context().extend({ fixtureScope: 'rpc-caller' })
await ctx.plugin(TypertRegistry)
await ctx.plugin(FakeConnectionService)
@@ -828,13 +836,18 @@ describe('TypertGatewayService', () => {
await gatewayFiber
await ctx.plugin(GoalService)
const connection = rawConnection(ctx)
expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' })
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
registerAgentLookup(ctx, { id: 'agent-1' })
registerStrict(ctx, [createDescriptor()])
expect(connection.matches?.('goals/create')).toBe(true)
expect(connection.matches?.('goals/passthrough')).toBe(true)
expect(connection.matches?.('goals')).toBe(false)
expect(connection.matches?.('goals/missing')).toBe(false)
expect(connection.matches?.('legacy/list')).toBe(false)
const signal = new AbortController().signal
const handler = connection.handler
if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler')
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
await expect(handler('goals/create', {
args: { agentId: 'agent-1', request: { title: 'ship' } },
}, signal)).resolves.toEqual({
@@ -873,7 +886,7 @@ describe('TypertGatewayService', () => {
expect(connection.handler).toBeUndefined()
})
it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => {
it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => {
const ctx = new Context().extend({ fixtureScope: 'http-caller' })
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
@@ -886,11 +899,12 @@ describe('TypertGatewayService', () => {
await goalFiber
const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' })
const removeStrict = registerStrict(ctx, [createDescriptor()])
let strictActive = true
expect(routes).toHaveLength(1)
const server = await serveRoute(routes[0]!)
try {
const response = await fetch(`${server.origin}/api2/goals/create`, {
const response = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -909,9 +923,54 @@ describe('TypertGatewayService', () => {
value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' },
},
})
const invalid = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: 'rpc-invalid',
method: 'goals/create',
payload: { invalid: true },
}),
})
expect(invalid.status).toBe(200)
await expect(invalid.json()).resolves.toMatchObject({
type: 'server-response',
rpcId: 'rpc-invalid',
result: {
ok: false,
error: { code: 'internal', message: expect.stringContaining('plain-object args field') },
},
})
await removeStrict()
strictActive = false
const withdrawn = await fetch(`${server.origin}/api/goals/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: 'rpc-withdrawn',
method: 'goals/create',
payload: { args: { agentId: 'agent-1', request: { title: 'ship' } } },
}),
})
expect(withdrawn.status).toBe(200)
await expect(withdrawn.json()).resolves.toMatchObject({
type: 'server-response',
rpcId: 'rpc-withdrawn',
result: {
ok: false,
error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') },
},
})
const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' })
expect(unclaimed.status).toBe(404)
} finally {
await server.close()
await removeStrict()
if (strictActive) await removeStrict()
await removeLookup()
await goalFiber.dispose()
await gatewayFiber.dispose()