Files
deepseek-harness/packages/api/gateway/src/client/index.ts

424 lines
16 KiB
TypeScript

/**
* 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)
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`)
}
}
}
}
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)
if (namespace.tokens.size === 0) this.scoped.delete(descriptor.namespace)
}
}
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 disposeService: (() => void) | undefined
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) {
this.disposeService = this.ownerCtx.reflect.provide(this.name, this)
}
} 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) return
const disposeService = this.disposeService
this.disposeService = undefined
disposeService?.()
}
}
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'disposeService', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
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 })
}