refactor(api): colocate gateway and remote assembly
This commit is contained in:
6
packages/api/README.i18n.yaml
Normal file
6
packages/api/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/api/README.md
|
||||
README.md: 0dcded5922fea1ea6676315029ba0eadd74dd3df
|
||||
README.zh.md: 1b9bb9133a955d0cbef0ca91728aab1545831d94
|
||||
17
packages/api/README.md
Normal file
17
packages/api/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/ — Remote API layers
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.api` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client API endpoint | `ctx.typertGateway` / `ctx.api` |
|
||||
|
||||
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientApi` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts.
|
||||
- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy.
|
||||
17
packages/api/README.zh.md
Normal file
17
packages/api/README.zh.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/:Remote API 层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.api` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client API endpoint | `ctx.typertGateway` / `ctx.api` |
|
||||
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientApi` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。
|
||||
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。
|
||||
6
packages/api/gateway/README.i18n.yaml
Normal file
6
packages/api/gateway/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/api/gateway/README.md
|
||||
README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261
|
||||
README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca
|
||||
39
packages/api/gateway/README.md
Normal file
39
packages/api/gateway/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.api`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
|
||||
|
||||
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteContext` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@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 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. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
## 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('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. 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 through the shared `TypeRTClientApi` contract. 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package dispatches application calls and registers no prompt, tool, or session event.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
39
packages/api/gateway/README.zh.md
Normal file
39
packages/api/gateway/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.api`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteContext` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteContext` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientApi`(ctx key:`api`)
|
||||
|
||||
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;被调用的业务服务负责产生任何模型可见结果。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
66
packages/api/gateway/package.json
Normal file
66
packages/api/gateway/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
423
packages/api/gateway/src/client/index.ts
Normal file
423
packages/api/gateway/src/client/index.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install concrete namespace methods; no JavaScript Proxy participates in
|
||||
* lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service, symbols } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientApi,
|
||||
TypeRTCodec,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
type RemoteMethod = (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
interface DirectNamespaceRecord {
|
||||
readonly value: Record<string, RemoteMethod>
|
||||
readonly tokens: Map<string, MountToken>
|
||||
}
|
||||
|
||||
interface ScopedNamespaceRecord {
|
||||
readonly service: ScopedRemoteNamespace
|
||||
readonly tokens: Map<string, MountToken>
|
||||
}
|
||||
|
||||
interface ScopedProjection {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
/** Typed API service augmented by generated direct Remote namespaces. */
|
||||
export type ClientApi = TypeRTClientApi
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated direct Remote namespaces selected by the Client assembly. */
|
||||
api: ClientApi
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client API service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientApiService(ctx)
|
||||
}
|
||||
|
||||
class ClientApiService extends Service implements TypeRTClientApi {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly direct = new Map<string, DirectNamespaceRecord>()
|
||||
private readonly scoped = new Map<string, ScopedNamespaceRecord>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'api')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientApi['mount']> {
|
||||
this.validateContribution(contribution)
|
||||
const callerCtx = this.ctx
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
let disposeMethods: () => void | Promise<void>
|
||||
try {
|
||||
disposeMethods = callerCtx.effect(() => {
|
||||
const installed: Array<() => void> = []
|
||||
try {
|
||||
for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor))
|
||||
} catch (error) {
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
}
|
||||
}, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */
|
||||
Promise.resolve(disposeRemote()).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([disposeMethods(), disposeRemote()])
|
||||
}
|
||||
}
|
||||
|
||||
private validateContribution(contribution: TypeRTRemoteContribution): void {
|
||||
const direct = new Map<string, Set<string>>()
|
||||
const scoped = new Map<string, Set<string>>()
|
||||
const add = (
|
||||
table: Map<string, Set<string>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
kind: 'direct' | 'scoped',
|
||||
): void => {
|
||||
const methods = table.get(descriptor.namespace) ?? new Set<string>()
|
||||
if (methods.has(descriptor.method)) {
|
||||
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
|
||||
}
|
||||
methods.add(descriptor.method)
|
||||
table.set(descriptor.namespace, methods)
|
||||
const live = kind === 'direct'
|
||||
? this.direct.get(descriptor.namespace)?.tokens
|
||||
: this.scoped.get(descriptor.namespace)?.tokens
|
||||
if (live?.has(descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
requireStrictDescriptor(descriptor)
|
||||
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
|
||||
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
|
||||
}
|
||||
for (const namespace of direct.keys()) {
|
||||
if (!this.direct.has(namespace) && namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the API service`)
|
||||
}
|
||||
}
|
||||
for (const [namespace, methods] of scoped) {
|
||||
const record = this.scoped.get(namespace)
|
||||
if (record !== undefined) {
|
||||
for (const method of methods) record.service.assertMethodAvailable(method)
|
||||
} else {
|
||||
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
|
||||
if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private install(descriptor: InvocationDescriptor): () => void {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: (() => void)[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
token.abort.abort()
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
token.abort.abort()
|
||||
}
|
||||
}
|
||||
|
||||
private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void {
|
||||
let namespace = this.direct.get(descriptor.namespace)
|
||||
const fresh = namespace === undefined
|
||||
if (namespace === undefined) {
|
||||
namespace = { value: Object.create(null) as Record<string, RemoteMethod>, tokens: new Map() }
|
||||
Object.defineProperty(this, descriptor.namespace, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: namespace.value,
|
||||
})
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(namespace.value, descriptor.method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args),
|
||||
})
|
||||
} catch (error) {
|
||||
if (fresh) Reflect.deleteProperty(this, descriptor.namespace)
|
||||
throw error
|
||||
}
|
||||
if (fresh) this.direct.set(descriptor.namespace, namespace)
|
||||
namespace.tokens.set(descriptor.method, token)
|
||||
return () => {
|
||||
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
Reflect.deleteProperty(namespace.value, descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
if (namespace.tokens.size !== 0) return
|
||||
this.direct.delete(descriptor.namespace)
|
||||
Reflect.deleteProperty(this, descriptor.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
): () => void {
|
||||
let namespace = this.scoped.get(descriptor.namespace)
|
||||
if (namespace === undefined) {
|
||||
const service = new ScopedRemoteNamespace(
|
||||
this.ownerCtx,
|
||||
descriptor.namespace,
|
||||
(current, currentProjection, currentToken, caller, args) =>
|
||||
this.invoke(current, currentProjection, currentToken, caller, args),
|
||||
)
|
||||
service.install(descriptor, projection, token)
|
||||
namespace = { service, tokens: new Map() }
|
||||
this.scoped.set(descriptor.namespace, namespace)
|
||||
} else {
|
||||
namespace.service.install(descriptor, projection, token)
|
||||
}
|
||||
namespace.tokens.set(descriptor.method, token)
|
||||
return () => {
|
||||
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||
namespace.service.remove(descriptor.method)
|
||||
namespace.tokens.delete(descriptor.method)
|
||||
}
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
const contract = descriptor.cancellation === undefined
|
||||
? `${String(expected)} argument(s)`
|
||||
: `${String(expected)} business argument(s) plus an optional AbortSignal`
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
if (binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = binder.identity(callerCtx)
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
|
||||
}
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, 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')
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class ScopedRemoteNamespace {
|
||||
private readonly ctx: Context
|
||||
private readonly ownerCtx: Context
|
||||
private readonly methods = new Set<string>()
|
||||
private provided = false
|
||||
readonly name: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) {
|
||||
throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
private readonly invokeRemote: InvokeRemote,
|
||||
) {
|
||||
this.ctx = ctx
|
||||
this.ownerCtx = ctx
|
||||
this.name = name
|
||||
Object.defineProperty(this, symbols.tracker, {
|
||||
value: { associate: name, property: 'ctx' },
|
||||
})
|
||||
}
|
||||
|
||||
assertMethodAvailable(method: string): void {
|
||||
ScopedRemoteNamespace.assertMethodAvailable(this.name, method)
|
||||
if (method in this) {
|
||||
throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.assertMethodAvailable(descriptor.method)
|
||||
const activate = this.methods.size === 0
|
||||
const method = descriptor.method
|
||||
try {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise<unknown> {
|
||||
return this.invokeRemote(descriptor, projection, token, this.ctx, args)
|
||||
},
|
||||
})
|
||||
if (activate) {
|
||||
if (this.provided) {
|
||||
this.ownerCtx.set(this.name, this)
|
||||
} else {
|
||||
this.ownerCtx.reflect.provide(this.name, this)
|
||||
this.provided = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Reflect.deleteProperty(this, method)
|
||||
throw error
|
||||
}
|
||||
this.methods.add(method)
|
||||
}
|
||||
|
||||
remove(method: string): void {
|
||||
Reflect.deleteProperty(this, method)
|
||||
this.methods.delete(method)
|
||||
if (this.methods.size === 0) this.ownerCtx.set(this.name, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided'])
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
function mountActive(token: MountToken): boolean {
|
||||
return token.active
|
||||
}
|
||||
|
||||
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
return {
|
||||
context: descriptor.invocation.context,
|
||||
wire: descriptor.invocation.wire,
|
||||
codec: descriptor.invocation.codec,
|
||||
}
|
||||
}
|
||||
if (descriptor.scope === undefined) return undefined
|
||||
const lookupParameters = descriptor.parameters
|
||||
.map((parameter, index) => ({ parameter, index }))
|
||||
.filter(candidate => candidate.parameter.source === 'lookup')
|
||||
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
if (selected === undefined
|
||||
|| selected.parameter.wire !== descriptor.scope.wire
|
||||
|| selected.parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
context: descriptor.scope.context,
|
||||
wire: descriptor.scope.wire,
|
||||
codec: selected.parameter.codec,
|
||||
parameterIndex: selected.index,
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
requireStrictCodec(descriptor.result, endpoint, 'result')
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
}
|
||||
|
||||
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
try {
|
||||
return codec.schema.parse(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
}
|
||||
637
packages/api/gateway/src/index.ts
Normal file
637
packages/api/gateway/src/index.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
/**
|
||||
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
readonly cause?: unknown
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
interface ResolvedBinding {
|
||||
readonly binding: TypeRTGatewayBinding
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
/** Machine-readable failure category. */
|
||||
readonly code: TypertGatewayErrorCode
|
||||
/** Canonical `<namespace>/<method>` endpoint. */
|
||||
readonly endpoint: string
|
||||
/** Affected wire field when the failure is field-specific. */
|
||||
readonly field: string | undefined
|
||||
|
||||
/**
|
||||
* Construct a Gateway failure without embedding boundary values in its message.
|
||||
* @param code - stable failure category.
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param message - correction-oriented diagnostic without sensitive values.
|
||||
* @param options - optional field and contained cause.
|
||||
*/
|
||||
constructor(
|
||||
code: TypertGatewayErrorCode,
|
||||
endpoint: string,
|
||||
message: string,
|
||||
options: GatewayErrorOptions = {},
|
||||
) {
|
||||
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
|
||||
this.name = 'TypertGatewayError'
|
||||
this.code = code
|
||||
this.endpoint = endpoint
|
||||
this.field = options.field
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
* @typert service typertGateway
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active TypeRT registry.
|
||||
* @param ctx - owning Host Context with TypeRT registry access.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typertGateway')
|
||||
ctx.on('internal/service', () => {
|
||||
this.srcClaims = undefined
|
||||
})
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
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
|
||||
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
|
||||
this.srcClaims ??= this.collectSrcClaims()
|
||||
return this.srcClaims.has(endpoint)
|
||||
}
|
||||
|
||||
private collectSrcClaims(): ReadonlySet<string> {
|
||||
const claims = new Set<string>()
|
||||
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) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
|
||||
const namespace = Reflect.get(binding, 'namespace') as string
|
||||
for (const candidate of remoteMethods(original)) {
|
||||
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
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)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const strict = this.ctx.typert.local.get(endpoint)
|
||||
if (strict !== undefined) return strict
|
||||
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
||||
throw new TypertGatewayError(
|
||||
'definition-unavailable',
|
||||
endpoint,
|
||||
'its strict definition was withdrawn and SRC fallback is forbidden',
|
||||
)
|
||||
}
|
||||
return this.resolveSrcDescriptor(namespace, method, endpoint)
|
||||
}
|
||||
|
||||
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const candidates: InvocationDescriptor[] = []
|
||||
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 value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) continue
|
||||
const binding = readBinding(value, original, serviceKey, endpoint)
|
||||
if (binding.namespace !== namespace) continue
|
||||
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
|
||||
if (marker === undefined) continue
|
||||
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'ambiguous-endpoint',
|
||||
endpoint,
|
||||
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
|
||||
)
|
||||
}
|
||||
return candidates[0] as InvocationDescriptor
|
||||
}
|
||||
|
||||
private srcDescriptor(
|
||||
binding: TypeRTGatewayBinding,
|
||||
marker: ReturnType<typeof remoteMethods>[number],
|
||||
method: string,
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const signalIndex = names.indexOf('signal')
|
||||
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
'SRC cancellation parameter signal must be the final parameter',
|
||||
{ field: 'signal' },
|
||||
)
|
||||
}
|
||||
const cancellation = signalIndex >= 0
|
||||
? { parameter: 'signal' as const }
|
||||
: undefined
|
||||
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of businessNames) {
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
|
||||
{ field: name },
|
||||
)
|
||||
}
|
||||
const match = matches[0]
|
||||
const parameter: InvocationParameterDescriptor = match === undefined
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
parameters.push(parameter)
|
||||
}
|
||||
|
||||
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
|
||||
if (marker.invocation.kind === 'context') {
|
||||
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (wires.has(provider.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
|
||||
{ field: provider.wire },
|
||||
)
|
||||
}
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: marker.invocation.context,
|
||||
wire: provider.wire,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `src:${binding.serviceKey}#${endpoint}`,
|
||||
service: binding.serviceKey,
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
private resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Context {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (provider.wire !== invocation.wire
|
||||
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} failed`,
|
||||
{ cause, field: invocation.wire },
|
||||
)
|
||||
}
|
||||
if (context === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-not-found',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private async resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<unknown> {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
const provider = this.ctx.typert.lookups.get(key)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} is unavailable`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (provider.wire !== parameter.wire
|
||||
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = await provider.resolve(value)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} failed`,
|
||||
{ cause, field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-not-found',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
function validateBinding(
|
||||
receiver: object,
|
||||
serviceKey: string,
|
||||
namespace: string,
|
||||
endpoint: string,
|
||||
): ResolvedBinding {
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
function readBinding(
|
||||
value: unknown,
|
||||
original: object,
|
||||
serviceKey: string,
|
||||
endpoint: string,
|
||||
namespace?: string,
|
||||
): TypeRTGatewayBinding {
|
||||
if (!isObject(value)
|
||||
|| Reflect.get(value, 'service') !== original
|
||||
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
||||
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
||||
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return value as unknown as TypeRTGatewayBinding
|
||||
}
|
||||
|
||||
function originalOf(receiver: object): object {
|
||||
const original = Reflect.get(receiver, symbols.original) as unknown
|
||||
return isObject(original) ? original : receiver
|
||||
}
|
||||
|
||||
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
|
||||
let prototype: object | null = Object.getPrototypeOf(service) as object | null
|
||||
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
|
||||
while (prototype !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
|
||||
if (descriptor !== undefined) {
|
||||
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
||||
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
|
||||
}
|
||||
break
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype) as object | null
|
||||
}
|
||||
if (implementation === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`Remote marker has no prototype method ${JSON.stringify(method)}`,
|
||||
)
|
||||
}
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
names.add(part)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function invalidSignature(endpoint: string, method: string): never {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
|
||||
)
|
||||
}
|
||||
|
||||
function assertExactArguments(
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
endpoint: string,
|
||||
): void {
|
||||
if (!isPlainObject(args)) {
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
|
||||
}
|
||||
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
|
||||
}
|
||||
|
||||
function decode(
|
||||
codec: TypeRTCodec,
|
||||
value: unknown,
|
||||
code: 'input-invalid' | 'result-invalid',
|
||||
endpoint: string,
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
code,
|
||||
endpoint,
|
||||
code === 'input-invalid'
|
||||
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
||||
: 'business result failed boundary validation',
|
||||
{ cause, field },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return
|
||||
throw new TypeError('non-finite number is not JSON-safe')
|
||||
}
|
||||
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
|
||||
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
||||
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')
|
||||
assertJsonValue(value[index], ancestors)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
assertJsonValue(descriptor.value, ancestors)
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
if (Array.isArray(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value) as object | null
|
||||
return prototype === null || prototype === Object.prototype
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
export default TypertGatewayService
|
||||
30
packages/api/gateway/src/invariant.ts
Normal file
30
packages/api/gateway/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`.
|
||||
* @module @deepseek-ai/dsh-api-gateway/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-gateway-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
54
packages/api/gateway/src/types.ts
Normal file
54
packages/api/gateway/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Carrier-independent TypeRT Gateway request, service, and error contracts.
|
||||
* @module @deepseek-ai/dsh-api-gateway/types
|
||||
*/
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
readonly namespace: string
|
||||
/** Exported Service method name. */
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
| 'arguments-invalid'
|
||||
| 'binding-invalid'
|
||||
| 'context-failed'
|
||||
| 'context-not-found'
|
||||
| 'context-unavailable'
|
||||
| 'definition-unavailable'
|
||||
| 'input-invalid'
|
||||
| 'invocation-unavailable'
|
||||
| 'lookup-failed'
|
||||
| 'lookup-not-found'
|
||||
| 'lookup-unavailable'
|
||||
| 'method-unavailable'
|
||||
| 'provider-mismatch'
|
||||
| 'result-invalid'
|
||||
| 'service-unavailable'
|
||||
| 'signature-invalid'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
}
|
||||
}
|
||||
545
packages/api/gateway/tests/client.spec.ts
Normal file
545
packages/api/gateway/tests/client.spec.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteContextApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteContextMap {
|
||||
'fixture:goals/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
let retained: typeof ctx.api.goals.create | undefined
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
retained = scope.api.goals.create
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.api.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
const cancellation = new Error('caller cancelled')
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
|
||||
})).toThrow('conflicts with the API service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).toThrow('repeats direct method')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).toThrow('direct method goals/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).toThrow('scoped method goals/rename is already mounted')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
})).toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('goals') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
})).toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).toThrow('conflicts with an existing Context property')
|
||||
|
||||
const disposeMultipleScoped = ctx.api.mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
it('rolls back direct projection when scoped installation fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const disposeScoped = ctx.api.mount({
|
||||
package: '@fixture/scoped-base',
|
||||
descriptors: [contextDescriptor()],
|
||||
})
|
||||
const defineProperty = Object.defineProperty
|
||||
let createDefinitions = 0
|
||||
const definePropertySpy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
// The direct projection defines `create` first; fail the following scoped projection.
|
||||
if (key === 'create' && ++createDefinitions === 2) throw new Error('simulated scoped installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/failing-install',
|
||||
descriptors: [directDescriptor()],
|
||||
})).toThrow('simulated scoped installation failure')
|
||||
} finally {
|
||||
definePropertySpy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals') !== undefined).toBe(true)
|
||||
expect(ctx.typert.remotes.list()).toHaveLength(1)
|
||||
|
||||
const disposeRetry = ctx.api.mount({
|
||||
package: '@fixture/retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
await disposeRetry()
|
||||
await disposeScoped()
|
||||
})
|
||||
|
||||
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'archive') throw new Error('fixture later-descriptor failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.toThrow('fixture later-descriptor failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.api.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.api.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: direct.parameters.map((parameter, index) => index === 0
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/weak-context',
|
||||
descriptors: [{
|
||||
...context,
|
||||
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
|
||||
} as InvocationDescriptor],
|
||||
})).toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: [...direct.parameters, {
|
||||
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = ctx.api.mount({
|
||||
package: '@fixture/goals',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
|
||||
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...base } = directDescriptor()
|
||||
const descriptor: InvocationDescriptor = {
|
||||
...base,
|
||||
id: '@fixture/goals#goals/prototype',
|
||||
method: 'prototype',
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: '__proto__',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
|
||||
}],
|
||||
}
|
||||
const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.api.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
|
||||
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
|
||||
expect(Object.getPrototypeOf(payload.args)).toBeNull()
|
||||
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
|
||||
expect(payload.args.__proto__).toBe('wire-value')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when concrete method installation fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'goals') throw new Error('fixture installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture installation failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.api.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh direct namespace when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'create') throw new Error('fixture direct method installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = ctx.api.mount({ package: '@fixture/direct-method-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.api.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'rename') throw new Error('fixture scoped installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.toThrow('fixture scoped installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
1301
packages/api/gateway/tests/gateway.spec.ts
Normal file
1301
packages/api/gateway/tests/gateway.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
packages/api/gateway/tsconfig.json
Normal file
27
packages/api/gateway/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/api/gateway/tsdown.config.ts
Normal file
3
packages/api/gateway/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/api/remotes/README.i18n.yaml
Normal file
6
packages/api/remotes/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/api/remotes/README.md
|
||||
README.md: cf54a56a849246d4efdca09cadd42e157064bdee
|
||||
README.zh.md: 5cd7ef21c926440ca4df6d88ee4adfe87defcc3f
|
||||
25
packages/api/remotes/README.md
Normal file
25
packages/api/remotes/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.api`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, concrete root and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientApi` interface through Cordis and does not import the concrete Gateway.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.api` contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
|
||||
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
|
||||
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.
|
||||
25
packages/api/remotes/README.zh.md
Normal file
25
packages/api/remotes/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.api` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、具体的根级方法和作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientApi` 接口,不导入具体 Gateway。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.api` 契约,均可复用其 Client face。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
|
||||
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
|
||||
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。
|
||||
64
packages/api/remotes/package.json
Normal file
64
packages/api/remotes/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
193
packages/api/remotes/src/agent-lookup.ts
Normal file
193
packages/api/remotes/src/agent-lookup.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
|
||||
export type ApiRemoteLookupError =
|
||||
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
|
||||
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
|
||||
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
|
||||
|
||||
/** Result of resolving one session identity to its live Agent. */
|
||||
export type ApiRemoteAgentResult =
|
||||
| { readonly agent: Agent }
|
||||
| { readonly error: ApiRemoteLookupError }
|
||||
|
||||
/** Resume configuration supplied by the owning Host composition. */
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Per-Agent defaults used when a cold identity must resume. */
|
||||
readonly agentOptions?: AgentOptions
|
||||
/** Host-specific Agent-scope composition completed before publication. */
|
||||
readonly setup?: AgentSetup
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
export class ApiRemoteSessionNotFound extends Error {}
|
||||
|
||||
/** Session identity whose lifecycle belongs to subagent routing. */
|
||||
export class ApiRemoteSubagentSessionOwnership extends Error {
|
||||
/**
|
||||
* Construct the ownership fence.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
*/
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether generic Host routing must leave an identity to subagent routing.
|
||||
* @param ctx - Host Context carrying the live Agent registry.
|
||||
* @param session - attached or live Session metadata.
|
||||
* @param agent - live Agent when one is registered.
|
||||
* @returns whether generic Remote and legacy API calls must reject the identity.
|
||||
*/
|
||||
export function hasApiRemoteSubagentOwner(
|
||||
ctx: Context,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable caller-facing ownership rejection.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
* @returns the existing `agent-busy` RPC shape.
|
||||
*/
|
||||
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
|
||||
return {
|
||||
code: 'agent-busy',
|
||||
message: `session "${sessionId}" is owned by subagent routing`,
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold served session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host Context carrying the optional persistence provider.
|
||||
* @param sessionId - durable identity to inspect.
|
||||
* @returns detached metadata and events for a servable session.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
|
||||
*/
|
||||
export async function inspectApiRemoteSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
const inspected = await persistence.inspect(sessionId)
|
||||
if (inspected.meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return { meta: inspected.meta, events: [...inspected.events] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
|
||||
* Live Agents are reused, ordinary cold sessions resume once per identity, and
|
||||
* subagent-owned identities retain the legacy `agent-busy` fence.
|
||||
* @param ctx - owning Host Context.
|
||||
* @param options - defaults and Agent-scope setup used only for cold resume.
|
||||
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
|
||||
*/
|
||||
export function createApiRemoteAgentResolver(
|
||||
ctx: Context,
|
||||
options: ApiRemoteAgentOptions,
|
||||
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live === undefined) return undefined
|
||||
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return { agent: live }
|
||||
}
|
||||
|
||||
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
let resume = resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectApiRemoteSession(ctx, sessionId)
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions },
|
||||
...options.setup === undefined ? {} : { setup: options.setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
}
|
||||
})()
|
||||
resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRemoteSessionNotFound) {
|
||||
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
||||
}
|
||||
if (error instanceof ApiRemoteSubagentSessionOwnership) {
|
||||
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
|
||||
}
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
})
|
||||
|
||||
return agentFor
|
||||
}
|
||||
26
packages/api/remotes/src/client/index.ts
Normal file
26
packages/api/remotes/src/client/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import type { TypeRTClientApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientApi as ClientApi } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated direct Remote namespaces selected by this Client assembly. */
|
||||
api: TypeRTClientApi
|
||||
}
|
||||
}
|
||||
|
||||
/** Required service: the typed Client API contribution mount. */
|
||||
export const inject = ['api']
|
||||
|
||||
/**
|
||||
* Mount the Host capabilities explicitly selected for this Client assembly.
|
||||
* @param ctx - Client Cordis root carrying the typed API service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.api.mount(goalsRemote)
|
||||
}
|
||||
18
packages/api/remotes/src/index.ts
Normal file
18
packages/api/remotes/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
inspectApiRemoteSession,
|
||||
} from './agent-lookup.ts'
|
||||
export type {
|
||||
ApiRemoteAgentOptions,
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
24
packages/api/remotes/src/invariant.ts
Normal file
24
packages/api/remotes/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Built-artifact smoke for the first generated Remote: plain Node boots the
|
||||
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const root = resolve(packageDir, '../../..')
|
||||
const artifact = (path: string): string => join(root, path)
|
||||
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
|
||||
|
||||
const requiredArtifacts = [
|
||||
'packages/client/connection/lib/client.js',
|
||||
'packages/client/connection/lib/index.js',
|
||||
'packages/api/remotes/lib/client.js',
|
||||
'packages/core/agent/lib/index.js',
|
||||
'packages/core/session/lib/index.js',
|
||||
'packages/goal/goal/lib/index.js',
|
||||
'packages/goal/goal/lib/typert.host.js',
|
||||
'packages/api/gateway/lib/client.js',
|
||||
'packages/api/gateway/lib/index.js',
|
||||
'packages/typert/registry/lib/client.js',
|
||||
'packages/typert/registry/lib/index.js',
|
||||
].every(path => existsSync(artifact(path)))
|
||||
|
||||
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
|
||||
const urls = Object.fromEntries(Object.entries({
|
||||
agent: 'packages/core/agent/lib/index.js',
|
||||
apiGatewayClient: 'packages/api/gateway/lib/client.js',
|
||||
apiGatewayHost: 'packages/api/gateway/lib/index.js',
|
||||
connectionClient: 'packages/client/connection/lib/client.js',
|
||||
connectionHost: 'packages/client/connection/lib/index.js',
|
||||
goal: 'packages/goal/goal/lib/index.js',
|
||||
goalTypert: 'packages/goal/goal/lib/typert.host.js',
|
||||
registryClient: 'packages/typert/registry/lib/client.js',
|
||||
registryHost: 'packages/typert/registry/lib/index.js',
|
||||
remotesClient: 'packages/api/remotes/lib/client.js',
|
||||
session: 'packages/core/session/lib/index.js',
|
||||
}).map(([key, path]) => [key, artifactUrl(path)]))
|
||||
const script = `
|
||||
import { createServer } from 'node:http'
|
||||
import * as cordis from 'cordis'
|
||||
|
||||
const urls = ${JSON.stringify(urls)}
|
||||
const { Context } = cordis
|
||||
const { default: AgentRegistry } = await import(urls.agent)
|
||||
const connectionHost = await import(urls.connectionHost)
|
||||
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
|
||||
const { default: GoalService } = await import(urls.goal)
|
||||
const { TYPERT } = await import(urls.goalTypert)
|
||||
const { default: TypertRegistry } = await import(urls.registryHost)
|
||||
const { Session, SessionId } = await import(urls.session)
|
||||
|
||||
const routes = []
|
||||
const host = new Context()
|
||||
host.provide('httpServer', {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex() { return () => {} },
|
||||
port: 0,
|
||||
})
|
||||
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
|
||||
await host.plugin(TypertRegistry)
|
||||
await host.plugin(AgentRegistry)
|
||||
await host.plugin(TypertGatewayService)
|
||||
await host.plugin(GoalService)
|
||||
host.typert.register(TYPERT)
|
||||
|
||||
const makeAgent = rawId => {
|
||||
const session = new Session(SessionId(rawId))
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: host.extend(),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send() {},
|
||||
updateInbox() { return 'not-found' },
|
||||
followup() {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
|
||||
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
|
||||
reserveTurnAdmission() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
const rootAgent = makeAgent('built-root-agent')
|
||||
const scopedAgent = makeAgent('built-scoped-agent')
|
||||
host.agents.register(rootAgent)
|
||||
host.agents.register(scopedAgent)
|
||||
|
||||
if (routes.length !== 1 || routes[0].path !== '/api') {
|
||||
throw new Error('Connection did not register exactly one /api route')
|
||||
}
|
||||
const server = createServer((request, response) => { void routes[0].handler(request, response) })
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
|
||||
const origin = 'http://127.0.0.1:' + String(address.port)
|
||||
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === 'cordis') return cordis
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
|
||||
let invalidRejected = false
|
||||
try {
|
||||
await client.api.goals.create(rootAgent.id, { objective: 1 })
|
||||
} catch {
|
||||
invalidRejected = true
|
||||
}
|
||||
const rootResult = await client.api.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.api.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidRejected,
|
||||
rootResult,
|
||||
rootEdit,
|
||||
scopedResult,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.events.length,
|
||||
scopedEvents: scopedAgent.session.events.length,
|
||||
}
|
||||
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
|
||||
const result = await runPlainNode(script)
|
||||
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
|
||||
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
|
||||
invalidRejected: boolean
|
||||
rootResult: { ref: { id: string; revision: number } }
|
||||
rootEdit: { objective: string; revision: number }
|
||||
scopedResult: { ref: { id: string; revision: number } }
|
||||
rootGoal: string
|
||||
scopedGoal: string
|
||||
rootEvents: number
|
||||
scopedEvents: number
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
invalidRejected: true,
|
||||
rootResult: { ref: { revision: 1 } },
|
||||
rootEdit: { objective: 'edited root goal', revision: 2 },
|
||||
scopedResult: { ref: { revision: 1 } },
|
||||
rootGoal: 'edited root goal',
|
||||
scopedGoal: 'scoped goal',
|
||||
rootEvents: 2,
|
||||
scopedEvents: 1,
|
||||
})
|
||||
expect(output.rootResult.ref.id).toMatch(/^goal-/)
|
||||
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Execute one ESM script without tsx or a TypeScript loader. */
|
||||
function runPlainNode(script: string): Promise<{
|
||||
readonly exitCode: number | null
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
}> {
|
||||
return new Promise((resolveRun) => {
|
||||
execFile(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 55_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolveRun({
|
||||
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
42
packages/api/remotes/tsconfig.json
Normal file
42
packages/api/remotes/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/api/remotes/tsdown.config.ts
Normal file
3
packages/api/remotes/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user