refactor(api): colocate gateway and remote assembly
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user