feat: add TypeRT remote gateway infrastructure

This commit is contained in:
imccyu
2026-08-05 11:17:47 +08:00
parent effd8e1ebd
commit 64a963da0b
98 changed files with 7812 additions and 444 deletions

View File

@@ -15,6 +15,10 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
@@ -22,14 +26,25 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [],
"platform": "web",
"immediately": true
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {

View File

@@ -0,0 +1,15 @@
/** Browser face of the shared TypeRT runtime registry. */
import type { Context } from 'cordis'
import { TypertRegistry } from '../service.ts'
/** Required services: none; this is the Client reflection root. */
export const inject: string[] = []
/**
* Install the same registry implementation used by the Host face.
* @param ctx - Client Cordis root.
*/
export function apply(ctx: Context): void {
new TypertRegistry(ctx)
}

View File

@@ -1,12 +1,7 @@
/**
* Runtime registry for generated Typert contributions. It owns live Zod
* instances and generated package reflection, but performs no TypeScript
* analysis or schema generation.
* @module @deepseek-ai/dsh-typert-registry
*/
/** Host entry for the shared TypeRT runtime registry. */
import { Context, Service } from 'cordis'
import { z } from 'zod'
import type { z } from 'zod'
import type { TypeRTDisposer } from '@deepseek-ai/dsh-type-meta'
import type {
TypertContribution,
TypertFace,
@@ -16,204 +11,17 @@ import type {
TypertSchemaRecord,
} from './types.ts'
export type {
TypertContribution,
TypertDocTag,
TypertDocumentation,
TypertEventModel,
TypertFace,
TypertMemberModel,
TypertObjectModel,
TypertPackageFilter,
TypertPackageModel,
TypertPackageRecord,
TypertSchema,
TypertSchemaFilter,
TypertSchemaRecord,
TypertServiceModel,
TypertTypeModel,
} from './types.ts'
export { default, TypertRegistry, typertEndpoint, typertKey, typertPackageKey } from './service.ts'
export type * from './types.ts'
declare module 'cordis' {
interface Context {
typert: TypertRegistry
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTService {
register(contribution: TypertContribution): TypeRTDisposer
get(key: string): TypertSchemaRecord | undefined
resolve(key: string): TypertSchemaRecord
list(filter?: TypertSchemaFilter): TypertSchemaRecord[]
getPackage(packageName: string, face?: TypertFace): TypertPackageRecord | undefined
listPackages(filter?: TypertPackageFilter): TypertPackageRecord[]
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
}
}
/**
* Compose the global key of one generated schema.
* @param packageName - contributing npm package.
* @param name - schema export name.
* @returns `<package>#<name>`.
*/
export function typertKey(packageName: string, name: string): string {
return `${packageName}#${name}`
}
/**
* Compose the identity of one package-face model.
* @param packageName - contributing npm package.
* @param face - independently compiled face.
* @returns `<package>#<face>`.
*/
export function typertPackageKey(packageName: string, face: TypertFace): string {
return `${packageName}#${face}`
}
/**
* Registry of generated schemas and package reflection.
* @typert service
*/
export class TypertRegistry extends Service {
private readonly schemas = new Map<string, TypertSchemaRecord>()
private readonly packages = new Map<string, TypertPackageRecord>()
constructor(ctx: Context) {
super(ctx, 'typert')
}
/**
* Register one generated contribution atomically for the calling fiber.
* Duplicate package-face identities or schema keys reject the whole batch.
* @param contribution - generated schemas and package metadata.
* @returns the exact effect disposer that removes this contribution.
*/
register(contribution: TypertContribution): () => void {
const packageRecord = this.validatePackage(contribution)
const schemaRecords = this.validateSchemas(contribution)
const { schemas, packages } = this
const dispose = this.ctx.effect(function* () {
packages.set(packageRecord.key, packageRecord)
for (const record of schemaRecords) schemas.set(record.key, record)
yield () => {
packages.delete(packageRecord.key)
for (const record of schemaRecords) schemas.delete(record.key)
}
}, 'typert.register()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve Cordis disposer identity
return dispose
}
/**
* Look up one schema by `<package>#<name>`.
* @param key - global schema key.
* @returns the live schema record, or `undefined` when absent.
*/
get(key: string): TypertSchemaRecord | undefined {
return this.schemas.get(key)
}
/**
* Resolve one required schema.
* @param key - global schema key.
* @returns the live schema record.
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
*/
resolve(key: string): TypertSchemaRecord {
const record = this.schemas.get(key)
if (record !== undefined) return record
const hash = key.indexOf('#')
if (hash <= 0 || hash === key.length - 1) {
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
}
const packageName = key.slice(0, hash)
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
throw new Error(
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
)
}
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
}
/**
* Enumerate live schemas in registration order.
* @param filter - optional package and face restriction.
* @returns matching schema records.
*/
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
return [...this.schemas.values()].filter(record => matches(record, filter))
}
/**
* Look up generated reflection for one package face.
* @param packageName - exact npm package name.
* @param face - face to query; defaults to the host runtime.
* @returns the live package record, or `undefined` when absent.
*/
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
return this.packages.get(typertPackageKey(packageName, face))
}
/**
* Enumerate generated package reflection in registration order.
* @param filter - optional package and face restriction.
* @returns matching package records.
*/
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
return [...this.packages.values()].filter(record => matches(record, filter))
}
/**
* Project a live Zod schema to JSON Schema without caching the result.
* @param key - global schema key.
* @param params - Zod projection parameters.
* @returns a fresh JSON Schema document.
*/
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
return z.toJSONSchema(this.resolve(key).schema, params)
}
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
validateSegment('package name', contribution.package)
const face: unknown = contribution.face
if (face !== 'host' && face !== 'client') {
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
}
const key = typertPackageKey(contribution.package, contribution.face)
if (this.packages.has(key)) {
throw new Error(`typert: package face "${key}" is already registered`)
}
return {
package: contribution.package,
face,
key,
model: contribution.model,
}
}
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
const records: TypertSchemaRecord[] = []
const batch = new Set<string>()
for (const schema of contribution.schemas) {
validateSegment('schema name', schema.name)
const key = typertKey(contribution.package, schema.name)
if (batch.has(key) || this.schemas.has(key)) {
throw new Error(`typert: schema "${key}" is already registered`)
}
batch.add(key)
records.push({
...schema,
package: contribution.package,
face: contribution.face,
key,
})
}
return records
}
}
function matches(
record: { readonly package: string; readonly face: TypertFace },
filter: { readonly package?: string; readonly face?: TypertFace },
): boolean {
return (filter.package === undefined || record.package === filter.package)
&& (filter.face === undefined || record.face === filter.face)
}
function validateSegment(subject: string, value: string): void {
if (value.length === 0 || value.includes('#')) {
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
}
}
export default TypertRegistry

View File

@@ -0,0 +1,584 @@
/**
* Runtime registry for generated TypeRT reflection, Remote invocations, and
* dependency-inverted lookup/Context providers. It performs no TypeScript
* analysis or schema generation.
* @module @deepseek-ai/dsh-typert-registry
*/
import { Context, Service } from 'cordis'
import { z } from 'zod'
import type {
InvocationDescriptor,
TypeRTClientContextBinder,
TypeRTContextMap,
TypeRTContextRegistry,
TypeRTContextWire,
TypeRTDisposer,
TypeRTHostContextProvider,
TypeRTLocalRegistry,
TypeRTLookupHost,
TypeRTLookupMap,
TypeRTLookupProvider,
TypeRTLookupRegistry,
TypeRTLookupWire,
TypeRTRemoteContribution,
TypeRTRemoteRegistry,
TypeRTRegistryChange,
TypeRTRegistryListener,
TypeRTService,
} from '@deepseek-ai/dsh-type-meta'
import type {
TypertContribution,
TypertFace,
TypertPackageFilter,
TypertPackageRecord,
TypertSchemaFilter,
TypertSchemaRecord,
} from './types.ts'
/**
* Compose the global key of one generated schema.
* @param packageName - contributing npm package.
* @param name - schema export name.
* @returns `<package>#<name>`.
*/
export function typertKey(packageName: string, name: string): string {
return `${packageName}#${name}`
}
/**
* Compose the identity of one package-face model.
* @param packageName - contributing npm package.
* @param face - independently compiled face.
* @returns `<package>#<face>`.
*/
export function typertPackageKey(packageName: string, face: TypertFace): string {
return `${packageName}#${face}`
}
/**
* Compose the endpoint key used by local and Remote invocation registries.
* @param descriptor - invocation whose namespace and method form the endpoint.
* @returns `<namespace>/<method>`.
*/
export function typertEndpoint(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
return `${descriptor.namespace}/${descriptor.method}`
}
interface DescriptorEntry {
readonly descriptor: InvocationDescriptor
readonly owner: object
}
interface ProviderEntry<Provider> {
readonly provider: Provider
readonly owner: object
}
type ReportObserverError = (change: TypeRTRegistryChange, error: unknown) => void
class ChangeSource {
private readonly listeners = new Set<TypeRTRegistryListener>()
constructor(private readonly report: ReportObserverError) {}
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
const { listeners } = this
return ctx.effect(function* () {
listeners.add(listener)
yield () => { listeners.delete(listener) }
}, 'typert registry subscription')
}
emit(change: TypeRTRegistryChange): void {
for (const listener of [...this.listeners]) {
try {
listener(change)
} catch (error) {
this.report(change, error)
}
}
}
}
class DescriptorStore {
private readonly entries = new Map<string, DescriptorEntry>()
private readonly ids = new Map<string, DescriptorEntry>()
private readonly history = new Set<string>()
private readonly changes: ChangeSource
constructor(
private readonly kind: 'local' | 'remote',
report: ReportObserverError,
) {
this.changes = new ChangeSource(report)
}
validate(descriptors: readonly InvocationDescriptor[]): void {
const endpoints = new Set<string>()
const ids = new Set<string>()
for (const descriptor of descriptors) {
validateInvocation(descriptor)
const endpoint = typertEndpoint(descriptor)
if (endpoints.has(endpoint) || this.entries.has(endpoint)) {
throw new Error(`typert: ${this.kind} endpoint "${endpoint}" is already registered`)
}
if (ids.has(descriptor.id) || this.ids.has(descriptor.id)) {
throw new Error(`typert: ${this.kind} invocation id "${descriptor.id}" is already registered`)
}
endpoints.add(endpoint)
ids.add(descriptor.id)
}
}
commit(owner: object, descriptors: readonly InvocationDescriptor[]): void {
for (const descriptor of descriptors) {
const entry = { descriptor, owner }
const endpoint = typertEndpoint(descriptor)
this.entries.set(endpoint, entry)
this.ids.set(descriptor.id, entry)
this.history.add(endpoint)
}
for (const descriptor of descriptors) {
this.changes.emit({ kind: this.kind, key: typertEndpoint(descriptor) })
}
}
withdraw(owner: object, descriptors: readonly InvocationDescriptor[]): void {
const removed: string[] = []
for (const descriptor of descriptors) {
const endpoint = typertEndpoint(descriptor)
const entry = this.entries.get(endpoint)
if (entry?.owner !== owner) continue
this.entries.delete(endpoint)
if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id)
removed.push(endpoint)
}
for (const endpoint of removed) this.changes.emit({ kind: this.kind, key: endpoint })
}
get(endpoint: string): InvocationDescriptor | undefined {
return this.entries.get(endpoint)?.descriptor
}
hasSeen(endpoint: string): boolean {
return this.history.has(endpoint)
}
list(): readonly InvocationDescriptor[] {
return [...this.entries.values()].map(entry => entry.descriptor)
}
subscribe(ctx: Context, listener: TypeRTRegistryListener): TypeRTDisposer {
return this.changes.subscribe(ctx, listener)
}
}
class RemoteStore {
private readonly packages = new Map<string, object>()
constructor(private readonly descriptors: DescriptorStore) {}
view(ctx: Context): TypeRTRemoteRegistry {
return {
register: contribution => this.register(ctx, contribution),
get: endpoint => this.descriptors.get(endpoint),
list: () => this.descriptors.list(),
subscribe: listener => this.descriptors.subscribe(ctx, listener),
}
}
private register(ctx: Context, contribution: TypeRTRemoteContribution): TypeRTDisposer {
validateSegment('Remote package name', contribution.package)
if (this.packages.has(contribution.package)) {
throw new Error(`typert: Remote package "${contribution.package}" is already registered`)
}
this.descriptors.validate(contribution.descriptors)
const owner = {}
const { packages, descriptors } = this
return ctx.effect(function* () {
packages.set(contribution.package, owner)
descriptors.commit(owner, contribution.descriptors)
yield () => {
if (packages.get(contribution.package) === owner) packages.delete(contribution.package)
descriptors.withdraw(owner, contribution.descriptors)
}
}, `typert.remotes.register(${JSON.stringify(contribution.package)})`)
}
}
class LookupStore {
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
private readonly changes: ChangeSource
constructor(report: ReportObserverError) {
this.changes = new ChangeSource(report)
}
view(ctx: Context): TypeRTLookupRegistry {
return {
register: <K extends Extract<keyof TypeRTLookupMap, string>>(
key: K,
provider: TypeRTLookupProvider<
TypeRTLookupHost<TypeRTLookupMap[K]>,
TypeRTLookupWire<TypeRTLookupMap[K]>
>,
) => this.register(ctx, key, provider),
get: key => this.providers.get(key)?.provider,
keys: () => [...this.providers.keys()],
subscribe: listener => this.changes.subscribe(ctx, listener),
}
}
private register<Host, Wire>(ctx: Context, key: string, provider: TypeRTLookupProvider<Host, Wire>): TypeRTDisposer {
validateSegment('lookup key', key)
validateSegment('lookup parameter', provider.parameter)
validateWireName('lookup wire field', provider.wire)
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 owner = {}
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
const { providers, changes } = this
return ctx.effect(function* () {
providers.set(key, entry)
changes.emit({ kind: 'lookup', key })
yield () => {
if (providers.get(key) !== entry) return
providers.delete(key)
changes.emit({ kind: 'lookup', key })
}
}, `typert.lookups.register(${JSON.stringify(key)})`)
}
}
class ContextStore {
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
private readonly changes: ChangeSource
constructor(report: ReportObserverError) {
this.changes = new ChangeSource(report)
}
view(ctx: Context): TypeRTContextRegistry {
return {
registerHost: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerHost(ctx, key, provider),
registerClient: <K extends Extract<keyof TypeRTContextMap, string>>(
key: K,
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
) => this.registerClient(ctx, key, binder),
getHost: key => this.hosts.get(key)?.provider,
getClient: key => this.clients.get(key)?.provider,
subscribe: listener => this.changes.subscribe(ctx, listener),
}
}
private registerHost<Wire>(ctx: Context, key: string, provider: TypeRTHostContextProvider<Wire>): TypeRTDisposer {
validateSegment('Context key', key)
validateWireName('Context wire field', provider.wire)
validateNonempty('Context wire type symbol', provider.wireTypeSymbol)
return this.registerProvider(ctx, this.hosts, 'host-context', key, provider)
}
private registerClient<Wire>(ctx: Context, key: string, binder: TypeRTClientContextBinder<Wire>): TypeRTDisposer {
validateSegment('Context key', key)
return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
}
private registerProvider<Provider>(
ctx: Context,
table: Map<string, ProviderEntry<Provider>>,
kind: 'host-context' | 'client-context',
key: string,
provider: Provider,
): TypeRTDisposer {
if (table.has(key)) throw new Error(`typert: ${kind} provider "${key}" is already registered`)
const entry: ProviderEntry<Provider> = { provider, owner: {} }
const { changes } = this
return ctx.effect(function* () {
table.set(key, entry)
changes.emit({ kind, key })
yield () => {
if (table.get(key) !== entry) return
table.delete(key)
changes.emit({ kind, key })
}
}, `typert.contexts.register(${JSON.stringify(key)})`)
}
}
/**
* Registry of generated schemas, package reflection, invocations, and Remote
* dependency providers.
* @typert service typert
*/
export class TypertRegistry extends Service implements TypeRTService {
private readonly schemas = new Map<string, TypertSchemaRecord>()
private readonly packages = new Map<string, TypertPackageRecord>()
private readonly localStore: DescriptorStore
private readonly remoteStore: RemoteStore
private readonly lookupStore: LookupStore
private readonly contextStore: ContextStore
constructor(ctx: Context) {
super(ctx, 'typert')
const report: ReportObserverError = (change, error) => {
ctx.logger.warn(`typert: ${change.kind} observer for "${change.key}" failed`)
ctx.logger.warn(error)
}
this.localStore = new DescriptorStore('local', report)
this.remoteStore = new RemoteStore(new DescriptorStore('remote', report))
this.lookupStore = new LookupStore(report)
this.contextStore = new ContextStore(report)
}
/** Current-environment invocation definitions. */
get local(): TypeRTLocalRegistry {
const ctx = this.ctx
return {
get: endpoint => this.localStore.get(endpoint),
hasSeen: endpoint => this.localStore.hasSeen(endpoint),
list: () => this.localStore.list(),
subscribe: listener => this.localStore.subscribe(ctx, listener),
}
}
/** Consumer-selected Remote definitions. */
get remotes(): TypeRTRemoteRegistry {
return this.remoteStore.view(this.ctx)
}
/** Host object lookup providers. */
get lookups(): TypeRTLookupRegistry {
return this.lookupStore.view(this.ctx)
}
/** Host Context providers and Client Context binders. */
get contexts(): TypeRTContextRegistry {
return this.contextStore.view(this.ctx)
}
/**
* Register one generated contribution atomically for the calling fiber.
* Duplicate package-face identities, schemas, invocation ids, or endpoints
* reject the whole batch.
* @param contribution - generated schemas, reflection, and Host invocations.
* @returns the exact effect disposer that removes this contribution.
*/
register(contribution: TypertContribution): TypeRTDisposer {
const packageRecord = this.validatePackage(contribution)
const schemaRecords = this.validateSchemas(contribution)
const invocations = contribution.invocations ?? []
this.localStore.validate(invocations)
const owner = {}
const { schemas, packages, localStore } = this
return this.ctx.effect(function* () {
packages.set(packageRecord.key, packageRecord)
for (const record of schemaRecords) schemas.set(record.key, record)
localStore.commit(owner, invocations)
yield () => {
if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key)
for (const record of schemaRecords) {
if (schemas.get(record.key) === record) schemas.delete(record.key)
}
localStore.withdraw(owner, invocations)
}
}, 'typert.register()')
}
/**
* Look up one schema by `<package>#<name>`.
* @param key - global schema key.
* @returns the live schema record, or `undefined` when absent.
*/
get(key: string): TypertSchemaRecord | undefined {
return this.schemas.get(key)
}
/**
* Resolve one required schema.
* @param key - global schema key.
* @returns the live schema record.
* @throws when the key is malformed, the package face is absent, or the schema is not contributed.
*/
resolve(key: string): TypertSchemaRecord {
const record = this.schemas.get(key)
if (record !== undefined) return record
const hash = key.indexOf('#')
if (hash <= 0 || hash === key.length - 1) {
throw new Error(`typert: invalid schema key "${key}" — expected "<package>#<name>"`)
}
const packageName = key.slice(0, hash)
if ([...this.packages.values()].some(candidate => candidate.package === packageName)) {
throw new Error(
`typert: cannot resolve "${key}" — package "${packageName}" is registered but contributes no schema named "${key.slice(hash + 1)}"`,
)
}
throw new Error(`typert: cannot resolve "${key}" — package "${packageName}" has no registered contribution`)
}
/**
* Enumerate live schemas in registration order.
* @param filter - optional package and face restriction.
* @returns matching schema records.
*/
list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] {
return [...this.schemas.values()].filter(record => matches(record, filter))
}
/**
* Look up generated reflection for one package face.
* @param packageName - exact npm package name.
* @param face - face to query; defaults to the host runtime.
* @returns the live package record, or `undefined` when absent.
*/
getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined {
return this.packages.get(typertPackageKey(packageName, face))
}
/**
* Enumerate generated package reflection in registration order.
* @param filter - optional package and face restriction.
* @returns matching package records.
*/
listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] {
return [...this.packages.values()].filter(record => matches(record, filter))
}
/**
* Project a live Zod schema to JSON Schema without caching the result.
* @param key - global schema key.
* @param params - Zod projection parameters.
* @returns a fresh JSON Schema document.
*/
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema {
return z.toJSONSchema(this.resolve(key).schema, params)
}
private validatePackage(contribution: TypertContribution): TypertPackageRecord {
validateSegment('package name', contribution.package)
const face: unknown = contribution.face
if (face !== 'host' && face !== 'client') {
throw new Error(`typert: invalid face ${JSON.stringify(face)} — expected "host" or "client"`)
}
const key = typertPackageKey(contribution.package, contribution.face)
if (this.packages.has(key)) {
throw new Error(`typert: package face "${key}" is already registered`)
}
return {
package: contribution.package,
face,
key,
model: contribution.model,
}
}
private validateSchemas(contribution: TypertContribution): TypertSchemaRecord[] {
const records: TypertSchemaRecord[] = []
const batch = new Set<string>()
for (const schema of contribution.schemas) {
validateSegment('schema name', schema.name)
const key = typertKey(contribution.package, schema.name)
if (batch.has(key) || this.schemas.has(key)) {
throw new Error(`typert: schema "${key}" is already registered`)
}
batch.add(key)
records.push({
...schema,
package: contribution.package,
face: contribution.face,
key,
})
}
return records
}
}
function matches(
record: { readonly package: string; readonly face: TypertFace },
filter: { readonly package?: string; readonly face?: TypertFace },
): boolean {
return (filter.package === undefined || record.package === filter.package)
&& (filter.face === undefined || record.face === filter.face)
}
function validateInvocation(descriptor: InvocationDescriptor): void {
validateNonempty('invocation id', descriptor.id)
validateSegment('invocation service key', descriptor.service)
validateWireName('invocation namespace', descriptor.namespace)
validateWireName('invocation method', descriptor.method)
if (descriptor.implementation !== undefined) {
validateWireName('invocation implementation method', descriptor.implementation)
}
validateCodec(descriptor.result, `${descriptor.id} result`)
const wires = new Set<string>()
for (const parameter of descriptor.parameters) {
validateWireName('parameter name', parameter.name)
validateWireName('parameter wire field', parameter.wire)
if (wires.has(parameter.wire)) {
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${parameter.wire}"`)
}
wires.add(parameter.wire)
if (parameter.source === 'lookup') {
if (parameter.lookup === undefined) {
throw new Error(`typert: invocation "${descriptor.id}" lookup parameter "${parameter.name}" has no lookup key`)
}
validateSegment('lookup key', parameter.lookup)
} else if (parameter.lookup !== undefined) {
throw new Error(`typert: invocation "${descriptor.id}" JSON parameter "${parameter.name}" declares a lookup key`)
}
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
}
if (descriptor.scope !== undefined) {
if (descriptor.invocation.kind !== 'direct') {
throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`)
}
validateSegment('scope Context key', descriptor.scope.context)
validateWireName('scope wire field', descriptor.scope.wire)
const lookups = descriptor.parameters.filter(candidate => candidate.source === 'lookup')
const parameter = lookups.length === 1 ? lookups[0] : undefined
if (parameter === undefined || parameter.wire !== descriptor.scope.wire
|| parameter.lookup !== descriptor.scope.context) {
throw new Error(
`typert: invocation "${descriptor.id}" scope wire "${descriptor.scope.wire}" must select its only lookup parameter`,
)
}
}
if (descriptor.invocation.kind === 'context') {
validateSegment('Context key', descriptor.invocation.context)
validateWireName('Context wire field', descriptor.invocation.wire)
if (wires.has(descriptor.invocation.wire)) {
throw new Error(`typert: invocation "${descriptor.id}" repeats wire field "${descriptor.invocation.wire}"`)
}
validateCodec(descriptor.invocation.codec, `${descriptor.id} Context`)
}
}
function validateCodec(codec: InvocationDescriptor['result'], subject: string): void {
if (codec.mode === 'src-json') return
validateNonempty(`${subject} type symbol`, codec.typeSymbol)
if (typeof codec.schema.parse !== 'function') {
throw new Error(`typert: ${subject} strict codec has no parse() method`)
}
}
function validateWireName(subject: string, value: string): void {
validateSegment(subject, value)
if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`)
}
function validateSegment(subject: string, value: string): void {
if (value.length === 0 || value.includes('#')) {
throw new Error(`typert: invalid ${subject} "${value}" — must be nonempty and must not contain "#"`)
}
}
function validateNonempty(subject: string, value: string): void {
if (value.length === 0) throw new Error(`typert: invalid ${subject} — must be nonempty`)
}
export default TypertRegistry

View File

@@ -5,6 +5,7 @@
*/
import type { z } from 'zod'
import type { InvocationDescriptor } from '@deepseek-ai/dsh-type-meta'
/** Independently compiled side that produced a contribution. */
export type TypertFace = 'host' | 'client'
@@ -82,6 +83,13 @@ 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 {
readonly invocations: readonly InvocationDescriptor[]
}
/** A live schema plus its contribution identity. */

View File

@@ -2,10 +2,27 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import TypertRegistry, {
typertEndpoint,
typertKey,
typertPackageKey,
type TypertContribution,
} from '@deepseek-ai/dsh-typert-registry'
import type {
InvocationDescriptor,
TypeRTContext,
TypeRTLookup,
TypeRTRemoteContribution,
} from '@deepseek-ai/dsh-type-meta'
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTLookupMap {
fixture: TypeRTLookup<{ readonly id: string }, string>
}
interface TypeRTContextMap {
registryFixture: TypeRTContext<string>
}
}
async function makeCtx(): Promise<Context> {
const ctx = new Context()
@@ -42,6 +59,42 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
}
}
function invocation(id = '@fixture/remote#goals/create'): InvocationDescriptor {
return {
id,
service: 'goals',
namespace: 'goals',
method: 'create',
invocation: { kind: 'direct' },
parameters: [{
name: 'request',
wire: 'request',
source: 'json',
codec: { mode: 'src-json' },
}],
result: { mode: 'src-json' },
}
}
function scopedInvocation(): InvocationDescriptor {
return {
...invocation('@fixture/remote#goals/create-scoped'),
scope: { context: 'fixture', wire: 'agentId' },
parameters: [{
name: 'agent',
wire: 'agentId',
source: 'lookup',
lookup: 'fixture',
codec: { mode: 'src-json' },
}, {
name: 'request',
wire: 'request',
source: 'json',
codec: { mode: 'src-json' },
}],
}
}
describe('TypertRegistry', () => {
it('registers and queries generated schemas separately from package reflection', async () => {
const ctx = await makeCtx()
@@ -69,7 +122,7 @@ describe('TypertRegistry', () => {
const dispose = ctx.typert.register(toolsContribution())
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeDefined()
dispose()
await dispose()
expect(ctx.typert.get('@deepseek-ai/dsh-tools#ToolInput')).toBeUndefined()
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
@@ -145,4 +198,133 @@ describe('TypertRegistry', () => {
expect(projected).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } })
expect(ctx.typert.toJSONSchema('@deepseek-ai/dsh-tools#ToolInput')).not.toBe(projected)
})
it('registers local invocations atomically with generated reflection', async () => {
const ctx = await makeCtx()
const descriptor = invocation()
const contribution = { ...toolsContribution(), invocations: [descriptor] }
const changes: string[] = []
ctx.typert.local.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
expect(ctx.typert.local.hasSeen('goals/create')).toBe(false)
const dispose = ctx.typert.register(contribution)
expect(typertEndpoint(descriptor)).toBe('goals/create')
expect(ctx.typert.local.get('goals/create')).toBe(descriptor)
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
expect(ctx.typert.local.list()).toEqual([descriptor])
expect(changes).toEqual(['local:goals/create'])
await dispose()
expect(ctx.typert.local.list()).toEqual([])
expect(ctx.typert.local.hasSeen('goals/create')).toBe(true)
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools')).toBeUndefined()
expect(changes).toEqual(['local:goals/create', 'local:goals/create'])
})
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
const ctx = await makeCtx()
const descriptor = invocation()
const contribution: TypeRTRemoteContribution = {
package: '@fixture/remote',
descriptors: [descriptor],
}
const changes: string[] = []
ctx.typert.remotes.subscribe((change) => { changes.push(`${change.kind}:${change.key}`) })
const fiber = ctx.plugin(Object.assign(
(child: Context) => { child.typert.remotes.register(contribution) },
{ inject: ['typert'] },
))
await fiber
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
expect(() => ctx.typert.remotes.register(contribution)).toThrow('Remote package')
await fiber.dispose()
expect(ctx.typert.remotes.list()).toEqual([])
expect(changes).toEqual(['remote:goals/create', 'remote:goals/create'])
})
it('accepts only a direct scope selecting its unique lookup parameter', async () => {
const ctx = await makeCtx()
const descriptor = scopedInvocation()
const dispose = ctx.typert.remotes.register({ package: '@fixture/scoped', descriptors: [descriptor] })
expect(ctx.typert.remotes.get('goals/create')).toBe(descriptor)
await dispose()
const cases: readonly [InvocationDescriptor, string][] = [
[{
...descriptor,
invocation: {
kind: 'context',
context: 'fixture',
wire: 'scopeId',
codec: { mode: 'src-json' },
},
}, 'Context receiver cannot declare a direct scope projection'],
[{ ...descriptor, scope: { context: 'fixture', wire: 'missingId' } }, 'must select its only lookup parameter'],
[{
...descriptor,
parameters: [...descriptor.parameters, {
name: 'other',
wire: 'otherId',
source: 'lookup',
lookup: 'fixture',
codec: { mode: 'src-json' },
}],
}, 'must select its only lookup parameter'],
[{ ...descriptor, scope: { context: 'other', wire: 'agentId' } }, 'must select its only lookup parameter'],
]
for (const [index, [candidate, message]] of cases.entries()) {
expect(() => ctx.typert.remotes.register({
package: `@fixture/rejected-${String(index)}`,
descriptors: [candidate],
})).toThrow(message)
}
expect(ctx.typert.remotes.list()).toEqual([])
})
it('registers lookup and Context providers without domain branches', async () => {
const ctx = await makeCtx()
const object = { id: 'agent-1' }
const scoped = ctx.extend()
const disposeLookup = ctx.typert.lookups.register('fixture', {
parameter: 'agent',
wire: 'agentId',
hostTypeSymbol: '@fixture/agent#Agent',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === object.id ? object : undefined,
})
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
wire: 'agentId',
wireTypeSymbol: '@fixture/session#SessionId',
resolve: id => id === object.id ? scoped : undefined,
})
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
identity: candidate => candidate === scoped ? object.id : undefined,
})
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
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.contexts.getHost('registryFixture')).toBeUndefined()
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
})
it('contains change-listener failures and still notifies later listeners', async () => {
const ctx = await makeCtx()
const warnings: unknown[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(message) }) as typeof ctx.logger.warn
let observed = 0
ctx.typert.remotes.subscribe(() => { throw new Error('observer failed') })
ctx.typert.remotes.subscribe(() => { observed += 1 })
ctx.typert.remotes.register({ package: '@fixture/remote', descriptors: [invocation()] })
expect(observed).toBe(1)
expect(warnings.map(String)).toContain('Error: observer failed')
})
})

View File

@@ -16,6 +16,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../type-meta"
}
]
}

View File

@@ -1,25 +1,3 @@
import { defineConfig } from 'tsdown'
import { clientBundle } from '../../client/tsdown.client.ts'
/** Build the registry and its invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
export default clientBundle('@deepseek-ai/dsh-typert-registry', ['lib/types/index.js', 'lib/types/invariant.js'])