feat: add TypeRT remote gateway infrastructure
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteContextApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteContextMap {
|
||||
'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Context & TypeRTRemoteContextApi<'fixture'>
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
let retained: typeof ctx.api.goals.create | undefined
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
retained = scope.api.goals.create
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.api as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => {
|
||||
scope.api.mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] })
|
||||
},
|
||||
{ inject: ['api'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api2',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
expect(() => ctx.api.mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.toThrow('has no strict codec')
|
||||
expect(() => ctx.api.mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: 'mount' }],
|
||||
})).toThrow('conflicts with the API service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.api.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,795 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
type InvocationDescriptor,
|
||||
type TypeRTContext,
|
||||
type TypeRTLookup,
|
||||
type TypeRTLookupProvider,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry'
|
||||
import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-host-api-gateway'
|
||||
|
||||
interface FixtureAgent {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface MarkedContext extends Context {
|
||||
readonly fixtureScope?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTLookupMap {
|
||||
gatewayFixture: TypeRTLookup<FixtureAgent, string>
|
||||
gatewayFixtureAlias: TypeRTLookup<FixtureAgent, string>
|
||||
}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
gatewayFixture: TypeRTContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
const emptyModel: TypertContribution['model'] = {
|
||||
services: [],
|
||||
events: [],
|
||||
objects: [],
|
||||
}
|
||||
|
||||
class GoalService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
readonly calls: string[] = []
|
||||
nextResult: unknown = undefined
|
||||
businessError: Error | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(agent: FixtureAgent, request: { readonly title: string }): unknown {
|
||||
this.calls.push('create')
|
||||
return {
|
||||
agentId: agent.id,
|
||||
title: request.title,
|
||||
scope: (this.ctx as MarkedContext).fixtureScope ?? 'root',
|
||||
}
|
||||
}
|
||||
|
||||
@RemoteContext('gatewayFixture')
|
||||
rename(request: { readonly title: string }): unknown {
|
||||
this.calls.push('rename')
|
||||
return { title: request.title, scope: (this.ctx as MarkedContext).fixtureScope ?? 'root' }
|
||||
}
|
||||
|
||||
@Remote
|
||||
passthrough(value: unknown): unknown {
|
||||
this.calls.push('passthrough')
|
||||
return this.nextResult === undefined ? value : this.nextResult
|
||||
}
|
||||
|
||||
@Remote
|
||||
fail(request: unknown): never {
|
||||
void request
|
||||
this.calls.push('fail')
|
||||
throw this.businessError ?? new Error('fixture business failure')
|
||||
}
|
||||
|
||||
strictOnly(request: { readonly title: string }): unknown {
|
||||
this.calls.push('strictOnly')
|
||||
return this.nextResult === undefined ? request : this.nextResult
|
||||
}
|
||||
}
|
||||
|
||||
type FakeRpcResult =
|
||||
| { readonly ok: true; readonly value: unknown }
|
||||
| { readonly ok: false; readonly error: { readonly code: 'internal'; readonly message: string; readonly details: object } }
|
||||
|
||||
type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<FakeRpcResult>
|
||||
|
||||
class FakeConnectionService extends Service {
|
||||
channel: string | undefined
|
||||
authority: string | undefined
|
||||
handler: FakeRpcHandler | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
get rpc() {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel: string, handler: FakeRpcHandler, options: { readonly authority: string }) =>
|
||||
owner.effect(() => {
|
||||
this.channel = channel
|
||||
this.authority = options.authority
|
||||
this.handler = handler
|
||||
return () => {
|
||||
this.channel = undefined
|
||||
this.authority = undefined
|
||||
this.handler = undefined
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; close(): Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
void route.handler(request, response)
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as AddressInfo
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(address.port)}`,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
class FirstSharedService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'firstShared', { namespace: 'shared' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'firstShared')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class SecondSharedService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'secondShared', { namespace: 'shared' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'secondShared')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'defaultParameter', { namespace: 'invalid-default' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'defaultParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value = 'fallback'): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class DestructuredParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'destructuredParameter', { namespace: 'invalid-destructure' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'destructuredParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run({ value }: { readonly value: string }): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
class RestParameterService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'restParameter', { namespace: 'invalid-rest' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'restParameter')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(...values: readonly unknown[]): string {
|
||||
return values.map(String).join(',')
|
||||
}
|
||||
}
|
||||
|
||||
class WrongBindingService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'wrongBinding')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(value: string): string {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
describe('TypertGatewayService', () => {
|
||||
it('invokes a strict direct method with schema decoding and a live lookup', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
registerAgentLookup(ctx, agent)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-caller' })
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: ' ship ' } },
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
|
||||
expect(service.calls).toEqual(['create'])
|
||||
})
|
||||
|
||||
it('resolves strict Remote Context identity without adding a business argument', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const scoped = ctx.extend({ fixtureScope: 'agent-scope' })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).resolves.toEqual({ title: 'land', scope: 'agent-scope' })
|
||||
expect(service.calls).toEqual(['rename'])
|
||||
})
|
||||
|
||||
it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
|
||||
const { ctx } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
registerAgentLookup(ctx, agent)
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-src' })
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
|
||||
})
|
||||
|
||||
it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => {
|
||||
const { ctx } = await setup()
|
||||
const scoped = ctx.extend({ fixtureScope: 'agent-src' })
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
})).resolves.toEqual({ title: 'land', scope: 'agent-src' })
|
||||
})
|
||||
|
||||
it('re-reads Service and providers on every strict invocation', async () => {
|
||||
const { ctx, serviceFiber } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
const disposeLookup = registerAgentLookup(ctx, agent)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
|
||||
await disposeLookup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-unavailable')
|
||||
|
||||
registerAgentLookup(ctx, agent)
|
||||
await serviceFiber.dispose()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'service-unavailable')
|
||||
})
|
||||
|
||||
it('re-reads and contains Context providers', async () => {
|
||||
const { ctx } = await setup()
|
||||
const scoped = ctx.extend()
|
||||
const dispose = ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped))
|
||||
registerStrict(ctx, [renameDescriptor()])
|
||||
|
||||
await dispose()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-unavailable')
|
||||
|
||||
ctx.typert.contexts.registerHost('gatewayFixture', {
|
||||
...contextProvider(scoped),
|
||||
resolve: () => { throw new Error('provider failed') },
|
||||
})
|
||||
const error = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
args: { agentId: 'agent-1', request: { title: 'land' } },
|
||||
}), 'context-failed')
|
||||
expect(error.cause).toEqual(new Error('provider failed'))
|
||||
})
|
||||
|
||||
it('never downgrades an observed strict endpoint after definition disposal', async () => {
|
||||
const { ctx } = await setup()
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await dispose()
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
await ctx.plugin(GoalService)
|
||||
await dispose()
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('retains the no-downgrade guard across Gateway Service reloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
await ctx.plugin(GoalService)
|
||||
const dispose = registerStrict(ctx, [passthroughDescriptor()])
|
||||
await dispose()
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: 'would pass through SRC' },
|
||||
}), 'definition-unavailable')
|
||||
})
|
||||
|
||||
it('rejects ambiguous SRC endpoints independently of reflection order', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(FirstSharedService)
|
||||
await ctx.plugin(SecondSharedService)
|
||||
|
||||
const error = await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'shared',
|
||||
method: 'run',
|
||||
args: { value: 'ship' },
|
||||
}), 'ambiguous-endpoint')
|
||||
expect(error.message).toContain('firstShared, secondShared')
|
||||
})
|
||||
|
||||
it('rejects SRC signatures that cannot map one wire field to each position', async () => {
|
||||
const cases = [
|
||||
{ plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
|
||||
{ plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
|
||||
{ plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
|
||||
] as const
|
||||
for (const testCase of cases) {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(testCase.plugin)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: testCase.namespace,
|
||||
method: 'run',
|
||||
args: testCase.args,
|
||||
}), 'signature-invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a SRC parameter matching more than one lookup provider', async () => {
|
||||
const { ctx } = await setup()
|
||||
const provider = agentLookup({ id: 'agent-1' })
|
||||
ctx.typert.lookups.register('gatewayFixture', provider)
|
||||
ctx.typert.lookups.register('gatewayFixtureAlias', provider)
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'signature-invalid')
|
||||
})
|
||||
|
||||
it('requires exact wire fields before invoking business code', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { request: { title: 'ship' } },
|
||||
}), 'arguments-invalid')
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true },
|
||||
}), 'arguments-invalid')
|
||||
expect(service.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('distinguishes strict input and result validation failures', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
registerStrict(ctx, [strictOnlyDescriptor()])
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
args: { request: { title: 1 } },
|
||||
}), 'input-invalid')
|
||||
|
||||
service.nextResult = { title: 1 }
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
args: { request: { title: 'ship' } },
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
1n,
|
||||
Symbol('value'),
|
||||
() => 'value',
|
||||
new Date(0),
|
||||
new Map(),
|
||||
[, 'sparse'],
|
||||
])('rejects non-JSON SRC input %#', async (value) => {
|
||||
const { ctx } = await setup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value },
|
||||
}), 'input-invalid')
|
||||
})
|
||||
|
||||
it('rejects cyclic SRC input and non-JSON SRC results', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const cyclic: { self?: unknown } = {}
|
||||
cyclic.self = cyclic
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: cyclic },
|
||||
}), 'input-invalid')
|
||||
|
||||
service.nextResult = new Date(0)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: { value: null },
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it('validates strict provider identity against generated wire metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.typert.lookups.register('gatewayFixture', {
|
||||
...agentLookup({ id: 'agent-1' }),
|
||||
wire: 'differentAgentId',
|
||||
})
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'provider-mismatch')
|
||||
})
|
||||
|
||||
it('validates binding identity and active method availability', async () => {
|
||||
const ctx = await setupGateway()
|
||||
await ctx.plugin(WrongBindingService)
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'wrong-binding',
|
||||
method: 'run',
|
||||
args: { value: 'ship' },
|
||||
}), 'binding-invalid')
|
||||
|
||||
await ctx.plugin(GoalService)
|
||||
registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }])
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'missing',
|
||||
args: { value: 'ship' },
|
||||
}), 'method-unavailable')
|
||||
})
|
||||
|
||||
it('preserves business exception identity after invocation begins', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const failure = new Error('business identity')
|
||||
service.businessError = failure
|
||||
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'fail',
|
||||
args: { request: { reason: 'fixture' } },
|
||||
})).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('reports an absent endpoint without retaining receiver state', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'absent',
|
||||
args: {},
|
||||
}), 'invocation-unavailable')
|
||||
})
|
||||
|
||||
it('mounts /api2 through an optional Connection and returns existing RPC results', async () => {
|
||||
const ctx = new Context().extend({ fixtureScope: 'rpc-caller' })
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(FakeConnectionService)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
await ctx.plugin(GoalService)
|
||||
const connection = rawConnection(ctx)
|
||||
expect(connection).toMatchObject({ channel: '/api2', authority: 'trusted-host' })
|
||||
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const signal = new AbortController().signal
|
||||
const handler = connection.handler
|
||||
if (handler === undefined) throw new Error('fixture Connection did not retain the /api2 handler')
|
||||
await expect(handler('goals/create', {
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
|
||||
})
|
||||
const invalid = await handler('goals/create', { invalid: true }, signal)
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dispatches a generated invocation through the real /api2 HTTP carrier', async () => {
|
||||
const ctx = new Context().extend({ fixtureScope: 'http-caller' })
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
|
||||
await connectionFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const gatewayFiber = ctx.plugin(TypertGatewayService)
|
||||
await gatewayFiber
|
||||
const goalFiber = ctx.plugin(GoalService)
|
||||
await goalFiber
|
||||
const removeLookup = registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
const removeStrict = registerStrict(ctx, [createDescriptor()])
|
||||
expect(routes).toHaveLength(1)
|
||||
const server = await serveRoute(routes[0]!)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${server.origin}/api2/goals/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'rpc-http',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1', request: { title: ' ship ' } } },
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-http',
|
||||
result: {
|
||||
ok: true,
|
||||
value: { agentId: 'agent-1', title: 'ship', scope: 'http-caller' },
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
await removeStrict()
|
||||
await removeLookup()
|
||||
await goalFiber.dispose()
|
||||
await gatewayFiber.dispose()
|
||||
await connectionFiber.dispose()
|
||||
}
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
async function setup(): Promise<{
|
||||
readonly ctx: Context
|
||||
readonly service: GoalService
|
||||
readonly serviceFiber: ReturnType<Context['plugin']>
|
||||
}> {
|
||||
const ctx = await setupGateway()
|
||||
const serviceFiber = ctx.plugin(GoalService)
|
||||
await serviceFiber
|
||||
return { ctx, service: rawGoalService(ctx), serviceFiber }
|
||||
}
|
||||
|
||||
async function setupGateway(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function rawGoalService(ctx: Context): GoalService {
|
||||
const receiver = ctx.get('goals') as unknown as GoalService & { [symbols.original]?: GoalService }
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
function rawConnection(ctx: Context): FakeConnectionService {
|
||||
const receiver = ctx.get('connection') as unknown as FakeConnectionService & {
|
||||
[symbols.original]?: FakeConnectionService
|
||||
}
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
|
||||
return ctx.typert.register({
|
||||
package: '@fixture/gateway',
|
||||
face: 'host',
|
||||
schemas: [],
|
||||
model: emptyModel,
|
||||
invocations: descriptors,
|
||||
})
|
||||
}
|
||||
|
||||
function registerAgentLookup(ctx: Context, agent: FixtureAgent): () => Promise<void> {
|
||||
return ctx.typert.lookups.register('gatewayFixture', agentLookup(agent))
|
||||
}
|
||||
|
||||
function agentLookup(agent: FixtureAgent): TypeRTLookupProvider<FixtureAgent, string> {
|
||||
return {
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/domain#Agent',
|
||||
wireTypeSymbol: '@fixture/domain#AgentId',
|
||||
resolve: id => id === agent.id ? agent : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function contextProvider(context: Context) {
|
||||
return {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/domain#AgentId',
|
||||
resolve: (id: string) => id === 'agent-1' ? context : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function strictCodec(typeSymbol: string, schema: z.ZodType): InvocationDescriptor['result'] {
|
||||
return { mode: 'strict', typeSymbol, schema }
|
||||
}
|
||||
|
||||
function createDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [
|
||||
{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'gatewayFixture',
|
||||
codec: strictCodec('@fixture/domain#AgentId', z.string()),
|
||||
},
|
||||
{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('@fixture/gateway#CreateRequest', z.object({
|
||||
title: z.string().transform(value => value.trim()),
|
||||
})),
|
||||
},
|
||||
],
|
||||
result: strictCodec('@fixture/gateway#CreateResult', z.object({
|
||||
agentId: z.string(),
|
||||
title: z.string(),
|
||||
scope: z.string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function renameDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'gatewayFixture',
|
||||
wire: 'agentId',
|
||||
codec: strictCodec('@fixture/domain#AgentId', z.string()),
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: strictCodec('@fixture/gateway#RenameRequest', z.object({ title: z.string() })),
|
||||
}],
|
||||
result: strictCodec('@fixture/gateway#RenameResult', z.object({
|
||||
title: z.string(),
|
||||
scope: z.string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function passthroughDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/gateway#goals/passthrough',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: 'value',
|
||||
source: 'json',
|
||||
codec: { mode: 'src-json' },
|
||||
}],
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
function strictOnlyDescriptor(): InvocationDescriptor {
|
||||
const value = strictCodec('@fixture/gateway#StrictValue', z.object({ title: z.string() }))
|
||||
return {
|
||||
id: '@fixture/gateway#goals/strictOnly',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: value }],
|
||||
result: value,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCode(
|
||||
promise: Promise<unknown>,
|
||||
code: TypertGatewayError['code'],
|
||||
): Promise<TypertGatewayError> {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypertGatewayError)
|
||||
expect(error).toMatchObject({ code })
|
||||
return error as TypertGatewayError
|
||||
}
|
||||
throw new Error(`expected TypertGatewayError ${code}`)
|
||||
}
|
||||
Reference in New Issue
Block a user