refactor(api): expose traced remote namespaces
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
|
||||
README.md: 9e3d4d89788bbc6edebfc0c0127999fed3ed9261
|
||||
README.zh.md: 9bbd46c71185a2fbf8da163565d6c19141c079ca
|
||||
README.md: e37359db71c1388667e9e61f538354711e90c0c1
|
||||
README.zh.md: 2054febb9a5423297c32b029b40a035062250aab
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
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.remote`; 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`)
|
||||
|
||||
@@ -14,13 +14,13 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
|
||||
|
||||
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`)
|
||||
## Client service: `ClientRemote` (ctx key: `remote`)
|
||||
|
||||
`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.
|
||||
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. 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.
|
||||
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
@@ -14,13 +14,13 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientApi`(ctx key:`api`)
|
||||
## Client 服务:`ClientRemote`(ctx key:`remote`)
|
||||
|
||||
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientApi` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install concrete namespace methods; no JavaScript Proxy participates in
|
||||
* lookup, invocation, or type exposure.
|
||||
* install traced `remote.<namespace>` services; no JavaScript Proxy
|
||||
* participates in method lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service, symbols } from 'cordis'
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientApi,
|
||||
TypeRTClientRemote,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
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
|
||||
@@ -39,13 +27,36 @@ interface ScopedProjection {
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
/** Typed API service augmented by generated direct Remote namespaces. */
|
||||
export type ClientApi = TypeRTClientApi
|
||||
interface DirectMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly token: MountToken
|
||||
}
|
||||
|
||||
interface ScopedMethod extends DirectMethod {
|
||||
readonly projection: ScopedProjection
|
||||
}
|
||||
|
||||
interface RemoteMethodRecord {
|
||||
direct?: DirectMethod
|
||||
scoped?: ScopedMethod
|
||||
}
|
||||
|
||||
interface BoundContextIdentity {
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface RemoteNamespaceHandle {
|
||||
readonly service: RemoteNamespaceService
|
||||
readonly dispose: TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypeRTClientRemote
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated direct Remote namespaces selected by the Client assembly. */
|
||||
api: ClientApi
|
||||
/** Generated Remote namespaces selected by the Client assembly. */
|
||||
remote: ClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,48 +64,56 @@ declare module 'cordis' {
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client API service.
|
||||
* Install the typed Client Remote service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientApiService(ctx)
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
class ClientApiService extends Service implements TypeRTClientApi {
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly direct = new Map<string, DirectNamespaceRecord>()
|
||||
private readonly scoped = new Map<string, ScopedNamespaceRecord>()
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'api')
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientApi['mount']> {
|
||||
this.validateContribution(contribution)
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
const callerCtx = this.ctx
|
||||
const owned = callerCtx.effect(async () => {
|
||||
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
|
||||
return () => this.enqueue(dispose)
|
||||
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
|
||||
await owned
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
private async mountContribution(
|
||||
callerCtx: Context,
|
||||
contribution: TypeRTRemoteContribution,
|
||||
): Promise<TypeRTDisposer> {
|
||||
this.validateContribution(contribution)
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
let disposeMethods: () => void | Promise<void>
|
||||
const installed: TypeRTDisposer[] = []
|
||||
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)})`)
|
||||
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */
|
||||
Promise.resolve(disposeRemote()).catch(() => {})
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await Promise.all([disposeMethods(), disposeRemote()])
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +131,8 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
}
|
||||
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) {
|
||||
const namespace = this.namespaces.get(descriptor.namespace)?.service
|
||||
if (namespace?.has(kind, descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
@@ -124,118 +141,151 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
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)
|
||||
const property = this.ownerCtx.reflect.props[namespace]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(namespace) !== undefined) {
|
||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
|
||||
for (const namespace of namespaces) {
|
||||
const service = this.namespaces.get(namespace)?.service
|
||||
if (service === undefined) {
|
||||
if (namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
|
||||
}
|
||||
const serviceKey = remoteServiceKey(namespace)
|
||||
const property = this.ownerCtx.reflect.props[serviceKey]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
|
||||
}
|
||||
}
|
||||
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
|
||||
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
|
||||
else service.assertMethodAvailable(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private install(descriptor: InvocationDescriptor): () => void {
|
||||
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: (() => void)[] = []
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(this.installDirect(descriptor, token))
|
||||
installed.push(await this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
||||
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
for (const dispose of installed.reverse()) dispose()
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
return async () => {
|
||||
/* 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()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
Object.defineProperty(namespace.value, descriptor.method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args),
|
||||
})
|
||||
namespace.service.installDirect(descriptor, token)
|
||||
} catch (error) {
|
||||
if (fresh) Reflect.deleteProperty(this, descriptor.namespace)
|
||||
await this.disposeNamespace(descriptor.namespace, 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)
|
||||
return async () => {
|
||||
if (!namespace.service.remove('direct', descriptor.method, token)) return
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private installScoped(
|
||||
private async 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)
|
||||
): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installScoped(descriptor, projection, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
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)
|
||||
if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace)
|
||||
return async () => {
|
||||
if (!namespace.service.remove('scoped', descriptor.method, token)) return
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
|
||||
let namespace = this.namespaces.get(name)
|
||||
if (namespace !== undefined) return namespace
|
||||
let service: RemoteNamespaceService | undefined
|
||||
const fiber = this.ownerCtx.plugin({
|
||||
name: remoteServiceKey(name),
|
||||
apply: (ctx: Context) => {
|
||||
service = new RemoteNamespaceService(
|
||||
ctx,
|
||||
name,
|
||||
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
|
||||
)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await fiber
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
|
||||
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
namespace = { service, dispose: fiber.dispose }
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
}
|
||||
|
||||
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
|
||||
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
|
||||
this.namespaces.delete(name)
|
||||
await namespace.dispose()
|
||||
}
|
||||
|
||||
private invokeMethod(
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
if (identity !== undefined) {
|
||||
return this.invoke(
|
||||
scoped.descriptor,
|
||||
scoped.projection,
|
||||
scoped.token,
|
||||
callerCtx,
|
||||
values,
|
||||
{ value: identity },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (direct !== undefined) {
|
||||
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
}
|
||||
if (scoped !== undefined) {
|
||||
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
}
|
||||
throw new Error('client api: Remote method is no longer mounted')
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
@@ -251,11 +301,15 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
if (binder === undefined) {
|
||||
const binder = boundIdentity === undefined
|
||||
? this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
: undefined
|
||||
if (boundIdentity === undefined && binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = binder.identity(callerCtx)
|
||||
const identity = boundIdentity === undefined
|
||||
? binder?.identity(callerCtx)
|
||||
: boundIdentity.value
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
@@ -281,23 +335,19 @@ class ClientApiService extends Service implements TypeRTClientApi {
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class ScopedRemoteNamespace {
|
||||
private readonly ctx: Context
|
||||
private readonly ownerCtx: Context
|
||||
private readonly methods = new Set<string>()
|
||||
private disposeService: TypeRTDisposer | undefined
|
||||
readonly name: string
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
private readonly namespace: 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`)
|
||||
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,54 +356,92 @@ class ScopedRemoteNamespace {
|
||||
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' },
|
||||
})
|
||||
super(ctx, remoteServiceKey(name))
|
||||
this.namespace = name
|
||||
}
|
||||
|
||||
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`)
|
||||
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
|
||||
if (method in this && !this.methods.has(method)) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${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
|
||||
get empty(): boolean {
|
||||
return this.methods.size === 0
|
||||
}
|
||||
|
||||
has(kind: 'direct' | 'scoped', method: string): boolean {
|
||||
return this.methods.get(method)?.[kind] !== undefined
|
||||
}
|
||||
|
||||
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
|
||||
this.install(descriptor.method, 'direct', { descriptor, token })
|
||||
}
|
||||
|
||||
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
|
||||
}
|
||||
|
||||
private install(method: string, kind: 'direct', value: DirectMethod): void
|
||||
private install(method: string, kind: 'scoped', value: ScopedMethod): void
|
||||
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
|
||||
this.assertMethodAvailable(method)
|
||||
let record = this.methods.get(method)
|
||||
const fresh = record === undefined
|
||||
record ??= {}
|
||||
if (record[kind] !== undefined) {
|
||||
throw new Error(`client api: ${kind} method ${this.namespace}/${method} is already mounted`)
|
||||
}
|
||||
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) {
|
||||
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
|
||||
if (fresh) {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
const scoped = current?.scoped
|
||||
return (...args: unknown[]) => {
|
||||
return this.invokeRemote(direct, scoped, callerCtx, args)
|
||||
}
|
||||
},
|
||||
})
|
||||
this.methods.set(method, record)
|
||||
}
|
||||
if (kind === 'direct') record.direct = value
|
||||
else record.scoped = value as ScopedMethod
|
||||
} catch (error) {
|
||||
Reflect.deleteProperty(this, method)
|
||||
if (kind === 'direct') delete record.direct
|
||||
else delete record.scoped
|
||||
if (fresh) {
|
||||
this.methods.delete(method)
|
||||
Reflect.deleteProperty(this, method)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.methods.add(method)
|
||||
}
|
||||
|
||||
remove(method: string): void {
|
||||
Reflect.deleteProperty(this, method)
|
||||
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): boolean {
|
||||
const record = this.methods.get(method)
|
||||
const current = record?.[kind]
|
||||
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
|
||||
if (record === undefined || current?.token !== token) return false
|
||||
if (kind === 'direct') delete record.direct
|
||||
else delete record.scoped
|
||||
if (record.direct !== undefined || record.scoped !== undefined) return true
|
||||
this.methods.delete(method)
|
||||
if (this.methods.size !== 0) return
|
||||
const disposeService = this.disposeService
|
||||
this.disposeService = undefined
|
||||
void disposeService?.()
|
||||
Reflect.deleteProperty(this, method)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
|
||||
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
|
||||
|
||||
function remoteServiceKey(namespace: string): string {
|
||||
return `remote.${namespace}`
|
||||
}
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from 'cordis'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -38,7 +38,7 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
|
||||
type FixtureContext = Omit<Context, 'remote'> & { readonly remote: TypeRTRemoteContextApi<'fixture'> }
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
@@ -105,17 +105,16 @@ describe('Client TypeRT API', () => {
|
||||
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 businessGoals = { owner: 'host business service' }
|
||||
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
retained = scope.api.goals.create
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
const retained = ctx.remote.goals.create
|
||||
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
@@ -123,7 +122,7 @@ describe('Client TypeRT API', () => {
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.api.goals.create(
|
||||
await expect(ctx.remote.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
@@ -135,16 +134,18 @@ describe('Client TypeRT API', () => {
|
||||
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"')
|
||||
await expect(ctx.remote.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 expect(ctx.remote.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.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBe(businessGoals)
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
disposeBusinessGoals()
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
@@ -156,26 +157,24 @@ describe('Client TypeRT API', () => {
|
||||
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'] },
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
await expect(agentCtx.remote.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 expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
@@ -187,25 +186,23 @@ describe('Client TypeRT API', () => {
|
||||
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'] },
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
await expect(agentCtx.remote.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' }))
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
@@ -215,12 +212,12 @@ describe('Client TypeRT API', () => {
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
|
||||
})).toThrow('conflicts with the API service')
|
||||
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
|
||||
})).rejects.toThrow('conflicts with the Remote service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -235,48 +232,50 @@ describe('Client TypeRT API', () => {
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).toThrow('repeats direct method')
|
||||
expect(() => ctx.api.mount({
|
||||
})).rejects.toThrow('repeats direct method')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).toThrow('repeats scoped method')
|
||||
})).rejects.toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
expect(() => ctx.api.mount({
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).toThrow('direct method goals/create is already mounted')
|
||||
})).rejects.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({
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).toThrow('scoped method goals/rename is already mounted')
|
||||
expect(() => ctx.api.mount({
|
||||
})).rejects.toThrow('scoped method goals/rename is already mounted')
|
||||
await expect(ctx.remote.$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
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('remote.goals') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
expect(() => ctx.api.mount({
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
})).toThrow('conflicts with its namespace service')
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
|
||||
expect(() => ctx.api.mount({
|
||||
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).toThrow('conflicts with an existing Context property')
|
||||
})).rejects.toThrow('conflicts with an existing Remote namespace')
|
||||
await disposeRemoteTypert()
|
||||
|
||||
const disposeMultipleScoped = ctx.api.mount({
|
||||
const disposeMultipleScoped = await ctx.remote.$mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
@@ -286,41 +285,6 @@ describe('Client TypeRT API', () => {
|
||||
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()
|
||||
@@ -335,17 +299,17 @@ describe('Client TypeRT API', () => {
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.toThrow('fixture later-descriptor failure')
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.rejects.toThrow('fixture later-descriptor failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect((ctx.remote 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')
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -353,7 +317,7 @@ describe('Client TypeRT API', () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
expect(() => ctx.api.mount({
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
@@ -361,19 +325,19 @@ describe('Client TypeRT API', () => {
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$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({
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
expect(() => ctx.api.mount({
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
@@ -382,7 +346,7 @@ describe('Client TypeRT API', () => {
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).toThrow('scope must select its only lookup parameter')
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
@@ -390,27 +354,29 @@ describe('Client TypeRT API', () => {
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = ctx.api.mount({
|
||||
const dispose = await ctx.remote.$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 create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).remote.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' }))
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ 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')
|
||||
await expect(ctx.remote.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 expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
@@ -427,14 +393,14 @@ describe('Client TypeRT API', () => {
|
||||
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' })
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.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()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||
@@ -453,9 +419,9 @@ describe('Client TypeRT API', () => {
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
|
||||
}],
|
||||
}
|
||||
const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.api.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
const method = (ctx.remote.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()
|
||||
@@ -464,23 +430,23 @@ describe('Client TypeRT API', () => {
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when concrete method installation fails', async () => {
|
||||
it('rolls back Remote registration when namespace Service startup 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')
|
||||
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture installation failure')
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.rejects.toThrow('fixture namespace startup 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')
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -492,16 +458,21 @@ describe('Client TypeRT API', () => {
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/direct-method-failure', descriptors: [directDescriptor()] }))
|
||||
.toThrow('fixture direct method installation failure')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect((ctx.remote 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')
|
||||
const retry = await ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -513,41 +484,41 @@ describe('Client TypeRT API', () => {
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.toThrow('fixture scoped installation failure')
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.rejects.toThrow('fixture scoped installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.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')
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = ctx.api.mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('goals')).toBeDefined()
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('remote.goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('goals', replacement)
|
||||
expect(ctx.get('goals')).toBe(replacement)
|
||||
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
|
||||
expect(ctx.get('remote.goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
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()] })
|
||||
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user