fix(typert): harden remote reflection boundaries

This commit is contained in:
imccyu
2026-08-06 17:49:35 +08:00
parent 88385a658e
commit 9b63d72c94
28 changed files with 813 additions and 116 deletions

View File

@@ -3097,7 +3097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TypertContribution',
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}',
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations: readonly InvocationDescriptor[];\n}',
},
{
name: 'TypeRTDisposer',

View File

@@ -284,6 +284,7 @@ class ScopedRemoteNamespace extends Service {
install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
this.assertMethodAvailable(descriptor.method)
if (this.methods.size === 0) this.ownerCtx.set(this.name, this)
const method = descriptor.method
Object.defineProperty(this, method, {
configurable: true,

View File

@@ -12,7 +12,6 @@ import {
type InvocationParameterDescriptor,
type TypeRTCodec,
type TypeRTGatewayBinding,
type TypeRTLookupProvider,
} from '@deepseek-ai/dsh-type-meta'
import type {
InvokeRemoteRequest,
@@ -149,6 +148,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
payload: unknown,
_signal: AbortSignal,
): Promise<ConnectionRpcResult> {
// Remote methods have no cancellation parameter yet, so disconnects do not cancel business work.
return this.invokeRpc(endpoint, payload)
}
@@ -229,10 +229,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
const parameters: InvocationParameterDescriptor[] = []
const wires = new Set<string>()
for (const name of names) {
const matches = this.ctx.typert.lookups.keys()
.map(key => ({ key, provider: this.ctx.typert.lookups.get(key) }))
.filter((entry): entry is { key: string; provider: TypeRTLookupProvider } =>
entry.provider?.parameter === name)
const matches = this.ctx.typert.lookups.definitions()
.filter(definition => definition.parameter === name)
if (matches.length > 1) {
throw new TypertGatewayError(
'signature-invalid',
@@ -246,7 +244,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
: {
name,
wire: match.provider.wire,
wire: match.wire,
source: 'lookup',
lookup: match.key,
codec: { mode: 'src-json' },
@@ -540,7 +538,7 @@ function decode(
field: string,
): unknown {
try {
if (codec.mode === 'strict') return codec.schema.parse(value)
if (codec.mode === 'strict') value = codec.schema.parse(value)
assertJsonValue(value, new Set())
return value
} catch (cause) {

View File

@@ -204,7 +204,13 @@ describe('Client TypeRT API', () => {
})
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { renamed: true } })
const ctx = await bench(call)
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
ctx.typert.contexts.registerClient('fixture', {
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
})
const direct = directDescriptor()
const context = contextDescriptor()
@@ -242,6 +248,13 @@ describe('Client TypeRT API', () => {
package: '@fixture/multiple-scoped',
descriptors: [directDescriptor(), contextDescriptor()],
})
await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
expect(call).toHaveBeenLastCalledWith(
'/api',
'goals/rename',
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
expect.any(AbortSignal),
)
await disposeMultipleScoped()
})

View File

@@ -370,6 +370,19 @@ describe('TypertGatewayService', () => {
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
})
it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
const { ctx, service } = await setup()
const dispose = registerAgentLookup(ctx, { id: 'agent-1' })
await dispose()
await expectCode(ctx.typertGateway.invoke({
namespace: 'goals',
method: 'create',
args: { agentId: 'agent-1', request: { title: 'ship' } },
}), 'lookup-unavailable')
expect(service.calls).toEqual([])
})
it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => {
const { ctx } = await setup()
const scoped = ctx.extend({ fixtureScope: 'agent-src' })
@@ -657,6 +670,22 @@ describe('TypertGatewayService', () => {
}), 'result-invalid')
})
it('rejects non-JSON values after strict codec validation', async () => {
const { ctx, service } = await setup()
const descriptor = strictOnlyDescriptor()
registerStrict(ctx, [{
...descriptor,
result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()),
}])
service.nextResult = 1n
await expectCode(ctx.typertGateway.invoke({
namespace: 'goals',
method: 'strictOnly',
args: { request: { title: 'ship' } },
}), 'result-invalid')
})
it.each([
undefined,
Number.NaN,

View File

@@ -1318,6 +1318,8 @@ class FaceAnalyzer {
* type evaluator.
*/
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
const resolvedType = this.checker.getTypeFromTypeNode(authoredType)
this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false)
const completed = new Map<ts.Type, TypeNodeId>()
const active = new Map<ts.Type, TypeNodeId>()
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
@@ -1474,7 +1476,107 @@ class FaceAnalyzer {
active.delete(type)
}
}
return convert(this.checker.getTypeFromTypeNode(authoredType))
return convert(resolvedType)
}
private assertRemoteJsonType(
type: ts.Type,
site: ts.TypeNode,
active: Set<ts.Type>,
allowUndefined: boolean,
): void {
const flags = type.flags
if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return
if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) {
this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`)
}
if ((flags & (ts.TypeFlags.BigIntLike | ts.TypeFlags.ESSymbolLike | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) {
this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`)
}
if ((flags & (ts.TypeFlags.StringLike
| ts.TypeFlags.NumberLike
| ts.TypeFlags.BooleanLike
| ts.TypeFlags.Null
| ts.TypeFlags.Never)) !== 0) return
if (type.isUnion()) {
for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined)
return
}
if (type.isIntersection()) {
const material = type.types.filter(member => !this.isRemotePhantomConstraint(member))
if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object')
for (const member of material) this.assertRemoteJsonType(member, site, active, false)
return
}
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
this.fail(site, 'Remote boundary contains an unresolved type parameter')
}
if ((flags & ts.TypeFlags.Object) === 0) {
this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`)
}
const symbol = type.getSymbol()
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
if (declaration !== undefined && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
this.fail(site, `Remote boundary contains class instance ${symbol?.name ?? this.checker.typeToString(type)}`)
}
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
this.fail(site, 'Remote boundary contains callable or constructable data')
}
if (active.has(type)) return
active.add(type)
try {
if (this.checker.isTupleType(type)) {
const reference = type as ts.TypeReference
const target = reference.target as ts.TupleType
const arguments_ = this.checker.getTypeArguments(reference)
arguments_.forEach((argument, index) => {
const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required
this.assertRemoteJsonType(
argument,
site,
active,
(elementFlags & ts.ElementFlags.Optional) !== 0,
)
})
return
}
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
if (element === undefined) this.fail(site, 'Remote boundary array has no element type')
this.assertRemoteJsonType(element, site, active, false)
return
}
const properties = this.checker.getPropertiesOfType(type)
if (properties.some(property => property.getName().startsWith('__@'))) {
this.fail(site, 'Remote boundary contains a symbol-keyed property')
}
for (const property of properties) {
const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0]
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, propertyDeclaration ?? site)
this.assertRemoteJsonType(
propertyType,
site,
active,
(property.flags & ts.SymbolFlags.Optional) !== 0,
)
}
for (const info of this.checker.getIndexInfosOfType(type)) {
if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) {
this.fail(site, 'Remote boundary contains a symbol index signature')
}
this.assertRemoteJsonType(info.type, site, active, false)
}
} finally {
active.delete(type)
}
}
private isRemotePhantomConstraint(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true
if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false
if (this.checker.getIndexInfosOfType(type).length > 0) return false
return this.checker.getPropertiesOfType(type).every(property => property.getName().startsWith('__@'))
}
private resolvedCycleReference(

View File

@@ -284,6 +284,32 @@ export type GenericResult = {
expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/)
})
it.each([
['bigint', 'bigint'],
['symbol', 'symbol'],
['undefined', 'undefined'],
['any', 'unconstrained any'],
['unknown', 'unconstrained unknown'],
])('rejects non-JSON Remote boundary type %s', (type, message) => {
const root = copyFixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace(
' readonly title: string\n}',
` readonly title: string\n readonly invalid: ${type}\n}`,
))
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
})
it('keeps optional JSON object fields valid', () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/types.ts', source => source.replace(
' readonly title: string\n}',
' readonly title: string\n readonly note?: string\n}',
))
expect(() => analyzeRemote(root)).not.toThrow()
})
it('rejects a Remote Context without a static Context declaration', () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')"))

View File

@@ -135,10 +135,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
requireMembers(pkgName, object.members, `object "${object.name as string}"`)
requireTypes(pkgName, object.types, `object "${object.name as string}"`)
}
if (manifest.invocations !== undefined) {
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
requireInvocation(pkgName, value)
}
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
requireInvocation(pkgName, value)
}
return manifest as unknown as TypertContribution
}

View File

@@ -60,6 +60,7 @@ function typertSource(pkgName: string, entryName: string): string {
' face: \'host\',',
` schemas: [{ name: '${entryName}', schema: ${entryName} }],`,
' model: { services: [], events: [], objects: [] },',
' invocations: [],',
'}',
'',
].join('\n')
@@ -262,6 +263,7 @@ describe('typert loader', () => {
' face: \'host\',',
' schemas: [{ name: \'Pending\', schema: Pending }],',
' model: { services: [], events: [], objects: [] },',
' invocations: [],',
'}',
'',
].join('\n'),
@@ -295,7 +297,7 @@ describe('typert loader', () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/broken', {
typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n',
typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] }, invocations: [] }\n',
})
const ctx = await boot()
await ctx.loader.create({ name: '@fixture/broken' })
@@ -410,6 +412,7 @@ describe('validateTypertManifest', () => {
face: 'host',
schemas: [{ name: 'A', schema: zodish }],
model: { services: [], events: [], objects: [] },
invocations: [],
}).schemas).toHaveLength(1)
expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object')
@@ -490,12 +493,14 @@ describe('validateTypertManifest', () => {
})).toThrow('object has a missing or empty exportName')
})
it('validates strict invocation descriptors and accepts legacy manifests without them', () => {
const legacy = completeManifest(zodish)
expect(validateTypertManifest('pkg', legacy)).toBe(legacy)
it('requires and validates strict invocation descriptors', () => {
const base = completeManifest(zodish)
const { invocations: _invocations, ...missingInvocations } = base
expect(() => validateTypertManifest('pkg', missingInvocations))
.toThrow('TYPERT.invocations must be an array')
const descriptor = strictInvocation()
const manifest = { ...legacy, invocations: [descriptor] }
const manifest = { ...base, invocations: [descriptor] }
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
const scoped = {
...descriptor,
@@ -508,53 +513,53 @@ describe('validateTypertManifest', () => {
codec: strictCodec('pkg#AgentId'),
}, ...descriptor.parameters],
}
expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations)
expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations)
.toEqual([scoped])
expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} }))
expect(() => validateTypertManifest('pkg', { ...base, invocations: {} }))
.toThrow('TYPERT.invocations must be an array')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...descriptor, invocation: { kind: 'future' } }],
})).toThrow('receiver kind must be "direct" or "context"')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
})).toThrow('result codec must use a strict codec')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],
})).toThrow('result codec is not backed by a zod v4 schema')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], source: 'future' }],
}],
})).toThrow('parameter source must be "json" or "lookup"')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], source: 'lookup' }],
}],
})).toThrow('lookup parameter has a missing or empty lookup')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }],
}],
})).toThrow('JSON parameter declares a lookup')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...descriptor,
parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }],
}],
})).toThrow('repeats wire field "request"')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...descriptor,
invocation: {
@@ -566,19 +571,19 @@ describe('validateTypertManifest', () => {
}],
})).toThrow('repeats Context wire field "request"')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...scoped, scope: null }],
})).toThrow('scope must be an object')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...scoped, scope: { wire: 'agentId' } }],
})).toThrow('scope has a missing or empty context')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...scoped, scope: { context: 'agent' } }],
})).toThrow('scope has a missing or empty wire')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...scoped,
invocation: {
@@ -590,11 +595,11 @@ describe('validateTypertManifest', () => {
}],
})).toThrow('Context receiver cannot declare a direct scope projection')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{
...scoped,
parameters: [...scoped.parameters, {
@@ -607,11 +612,11 @@ describe('validateTypertManifest', () => {
}],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
...base,
invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }],
})).toThrow('sourceLocation.line must be a positive integer')
})
@@ -646,6 +651,7 @@ function completeManifest(zodish: object) {
package: 'pkg',
face: 'host',
schemas: [{ name: 'Schema', schema: zodish }],
invocations: [],
model: {
services: [{
key: 'service',

View File

@@ -17,6 +17,7 @@ import type {
TypeRTHostContextProvider,
TypeRTLocalRegistry,
TypeRTLookupHost,
TypeRTLookupDefinition,
TypeRTLookupMap,
TypeRTLookupProvider,
TypeRTLookupRegistry,
@@ -212,6 +213,7 @@ class RemoteStore {
class LookupStore {
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
private readonly definitions = new Map<string, TypeRTLookupDefinition>()
private readonly changes: ChangeSource
constructor(report: ReportObserverError) {
@@ -228,6 +230,7 @@ class LookupStore {
>,
) => this.register(ctx, key, provider),
get: key => this.providers.get(key)?.provider,
definitions: () => [...this.definitions.values()],
keys: () => [...this.providers.keys()],
subscribe: listener => this.changes.subscribe(ctx, listener),
}
@@ -240,10 +243,22 @@ class LookupStore {
validateNonempty('lookup Host type symbol', provider.hostTypeSymbol)
validateNonempty('lookup wire type symbol', provider.wireTypeSymbol)
if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`)
const definition: TypeRTLookupDefinition = {
key,
parameter: provider.parameter,
wire: provider.wire,
hostTypeSymbol: provider.hostTypeSymbol,
wireTypeSymbol: provider.wireTypeSymbol,
}
const known = this.definitions.get(key)
if (known !== undefined && !lookupDefinitionEquals(known, definition)) {
throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`)
}
const owner = {}
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
const { providers, changes } = this
const { definitions, providers, changes } = this
return ctx.effect(function* () {
definitions.set(key, definition)
providers.set(key, entry)
changes.emit({ kind: 'lookup', key })
yield () => {
@@ -256,6 +271,13 @@ class LookupStore {
}
}
function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean {
return left.parameter === right.parameter
&& left.wire === right.wire
&& left.hostTypeSymbol === right.hostTypeSymbol
&& left.wireTypeSymbol === right.wireTypeSymbol
}
class ContextStore {
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
@@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService {
register(contribution: TypertContribution): TypeRTDisposer {
const packageRecord = this.validatePackage(contribution)
const schemaRecords = this.validateSchemas(contribution)
const invocations = contribution.invocations ?? []
const invocations = contribution.invocations
this.localStore.validate(invocations)
const owner = {}
const { schemas, packages, localStore } = this

View File

@@ -83,12 +83,7 @@ export interface TypertContribution {
readonly face: TypertFace
readonly schemas: readonly TypertSchema[]
readonly model: TypertPackageModel
/** Host invocation definitions; absent on artifacts generated before Remote support. */
readonly invocations?: readonly InvocationDescriptor[]
}
/** Generated Host contribution with strict Remote invocation definitions. */
export interface TypertLocalContribution extends TypertContribution {
/** Host invocation definitions, empty when the package exports no Remote methods. */
readonly invocations: readonly InvocationDescriptor[]
}

View File

@@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
package: '@deepseek-ai/dsh-tools',
face: 'host',
schemas: [{ name: 'ToolInput', schema }],
invocations: [],
model: {
services: [{
key: 'tools',
@@ -329,11 +330,19 @@ describe('TypertRegistry', () => {
})
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
expect(ctx.typert.lookups.definitions()).toEqual([{
key: 'fixture',
parameter: 'agent',
wire: 'agentId',
hostTypeSymbol: '@fixture/agent#Agent',
wireTypeSymbol: '@fixture/session#SessionId',
}])
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
expect(ctx.typert.lookups.keys()).toEqual([])
expect(ctx.typert.lookups.definitions()).toHaveLength(1)
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
})
@@ -378,6 +387,15 @@ describe('TypertRegistry', () => {
])
await Promise.all([disposeLookupSubscription(), disposeContextSubscription()])
for (const changed of [
{ ...lookup, parameter: 'session' },
{ ...lookup, wire: 'sessionId' },
{ ...lookup, hostTypeSymbol: '@fixture#Session' },
{ ...lookup, wireTypeSymbol: '@fixture#SessionId' },
]) {
expect(() => ctx.typert.lookups.register('fixture', changed))
.toThrow('changed its wire declaration during this registry lifetime')
}
ctx.typert.lookups.register('fixture', lookup)
expect(changes).toHaveLength(6)
})

View File

@@ -20,6 +20,7 @@ export type {
TypeRTHostContextProvider,
TypeRTLocalRegistry,
TypeRTLookup,
TypeRTLookupDefinition,
TypeRTLookupHost,
TypeRTLookupMap,
TypeRTLookupProvider,

View File

@@ -189,6 +189,20 @@ export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
resolve(id: Wire): Host | undefined
}
/** Stable wire declaration retained after a lookup provider unloads. */
export interface TypeRTLookupDefinition {
/** Merge-declared lookup key. */
readonly key: string
/** Source parameter name recognized by the SRC weak parser. */
readonly parameter: string
/** Wire field replacing the Host object parameter. */
readonly wire: string
/** Canonical Host type symbol used by strict generation. */
readonly hostTypeSymbol: string
/** Canonical wire type symbol used by strict generation. */
readonly wireTypeSymbol: string
}
/** Host resolver for one scoped Remote Context kind. */
export interface TypeRTHostContextProvider<Wire = unknown> {
/** Wire field carrying the Context identity. */
@@ -291,6 +305,8 @@ export interface TypeRTLookupRegistry {
* @returns the live provider, or `undefined` when absent.
*/
get(key: string): TypeRTLookupProvider | undefined
/** @returns lookup declarations observed during this TypeRT Service lifetime. */
definitions(): readonly TypeRTLookupDefinition[]
/** @returns a snapshot of registered provider keys. */
keys(): readonly string[]
/**