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

@@ -30,6 +30,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.13",
"typescript": "^6.0.3"
},
"peerDependencies": {

View File

@@ -15,6 +15,8 @@ import type {
EnumMemberModel,
ExportModel,
FaceModel,
InvocationModel,
InvocationParameterModel,
JsDocTagModel,
KeywordTypeName,
MemberBase,
@@ -23,6 +25,8 @@ import type {
ObjectModel,
PackageModel,
ParameterModel,
RemoteBoundaryModel,
RemoteTypeImportModel,
SchemaModel,
ServiceModel,
SignatureModel,
@@ -122,6 +126,25 @@ interface ModuleIdentity {
readonly subpath: string
}
interface StaticLookupDeclaration {
readonly key: string
readonly hostSymbol: SymbolId
readonly wireType: ts.TypeNode
readonly site: ts.Node
}
interface StaticContextDeclaration {
readonly key: string
readonly wireType: ts.TypeNode
readonly site: ts.Node
}
interface GatewayBinding {
readonly service: string
readonly namespace: string
readonly site: ts.PropertyDeclaration
}
type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
@@ -453,15 +476,11 @@ export class WorkspaceAnalyzer {
config: this.caches.config(configPath),
manifest,
}
const packagePath = slash(relative(this.options.root, packageRoot))
const clientPackage = packagePath === 'packages/client' || packagePath.startsWith('packages/client/')
if (clientPackage && isDualFacePackage(manifest)) {
if (isDualFacePackage(manifest)) {
registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) })
registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) })
} else if (clientPackage) {
registrations.push({ ...registration, face: 'client' })
} else {
registrations.push({ ...registration, face: 'host' })
registrations.push(registration)
}
}
}
@@ -480,6 +499,7 @@ export class WorkspaceAnalyzer {
&& subpath !== './package.json'
&& subpath !== './typert'
&& subpath !== './client/typert'
&& subpath !== './remote'
&& !target.endsWith('.json'))
.map(([, target]) => sourcePathForExport(registration.root, target))
.filter(existsSync)
@@ -578,6 +598,8 @@ class FaceAnalyzer {
private readonly nodes = new Map<TypeNodeId, TypeNodeModel>()
private readonly exportsByPackage = new Map<string, ExportRecord[]>()
private readonly nodeOrdinals = new Map<string, number>()
private staticLookups: readonly StaticLookupDeclaration[] | undefined
private staticContexts: ReadonlyMap<string, StaticContextDeclaration> | undefined
constructor(options: FaceAnalyzerOptions) {
this.root = options.root
@@ -601,6 +623,7 @@ class FaceAnalyzer {
const packages = this.registrations
.map(registration => this.analyzePackage(registration))
.filter(hasPackageSurface)
this.validateInvocationIdentity(packages)
return {
face: this.face,
packages,
@@ -634,6 +657,7 @@ class FaceAnalyzer {
}
}
}
const explicitServices = this.collectExplicitServices(records)
const objects: ObjectModel[] = []
const schemas: SchemaModel[] = []
@@ -672,10 +696,14 @@ class FaceAnalyzer {
root: slash(relative(this.root, registration.root)),
exports: records.map(record => record.model)
.sort((left, right) => left.subpath.localeCompare(right.subpath) || left.name.localeCompare(right.name)),
services: uniqueBy(services, service => service.key).sort((left, right) => left.key.localeCompare(right.key)),
services: uniqueBy([...explicitServices, ...services], service => service.key)
.sort((left, right) => left.key.localeCompare(right.key)),
events: uniqueBy(events, event => event.name).sort((left, right) => left.name.localeCompare(right.name)),
objects: objects.sort((left, right) => left.export.name.localeCompare(right.export.name)),
schemas: schemas.sort((left, right) => left.export.name.localeCompare(right.export.name)),
invocations: this.face === 'host'
? this.collectInvocations(registration, reachable).sort((left, right) => left.id.localeCompare(right.id))
: [],
}
}
@@ -686,7 +714,7 @@ class FaceAnalyzer {
const records: ExportRecord[] = []
for (const [subpath, target] of targets) {
if (target.includes('*') || subpath === './package.json'
|| subpath === './typert' || subpath === './client/typert'
|| subpath === './typert' || subpath === './client/typert' || subpath === './remote'
// Data exports (bundle patch lists, JSON manifests) carry no TypeScript API.
|| target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue
const sourcePath = sourcePathForExport(registration.root, target)
@@ -849,6 +877,740 @@ class FaceAnalyzer {
return result
}
private collectExplicitServices(records: readonly ExportRecord[]): ServiceModel[] {
const result: ServiceModel[] = []
const seen = new Set<SymbolId>()
for (const record of records) {
const tag = typertServiceTag(record.declaration)
if (tag === undefined) continue
const words = (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/)
if (words.length !== 2 || !isRemoteSegment(words[1] ?? '')) {
this.fail(tag, '@typert service requires exactly one nonempty Cordis service key without "/"')
}
if (!ts.isClassDeclaration(record.declaration)) {
this.fail(record.declaration, '@typert service requires an exported class')
}
const symbol = this.resolveSymbol(record.symbol)
const symbolId = this.symbolId(symbol)
if (seen.has(symbolId)) continue
seen.add(symbolId)
const model = this.ensureDeclaration(symbol, record.declaration)
result.push({
...documentationOf(record.declaration),
key: words[1] as string,
symbol: symbolId,
export: record.model,
members: model.members.filter(exposableMember).map(member => member.id),
location: this.location(record.declaration),
})
}
return result
}
private collectInvocations(
registration: PackageRegistration,
reachable: readonly ts.SourceFile[],
): InvocationModel[] {
const result: InvocationModel[] = []
for (const sourceFile of reachable) {
for (const statement of sourceFile.statements) {
if (!ts.isClassDeclaration(statement)) continue
const marked = statement.members.flatMap((member) => {
const invocation = this.remoteMarker(member)
if (invocation === undefined) return []
if (!ts.isMethodDeclaration(member)) {
this.fail(member, 'Remote decorators require a public instance method')
}
return [{ method: member, invocation }]
})
const first = marked[0]
if (first === undefined) continue
const binding = this.gatewayBinding(statement)
if (binding === undefined) {
this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)')
}
for (const { method, invocation } of marked) {
result.push(this.invocationModel(registration, binding, method, invocation))
}
}
}
return result
}
private invocationModel(
registration: PackageRegistration,
binding: GatewayBinding,
method: ts.MethodDeclaration,
invocation:
| { readonly kind: 'direct'; readonly exportName?: string }
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
): InvocationModel {
if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
this.fail(method, 'Remote decorators require a public instance method')
}
if (hasModifier(method, ts.SyntaxKind.AbstractKeyword) || method.body === undefined) {
this.fail(method, 'Remote methods must have a concrete implementation')
}
if (!ts.isIdentifier(method.name)) {
this.fail(method, 'Remote method names must be identifiers')
}
if ((method.typeParameters?.length ?? 0) > 0) {
this.fail(method, 'generic Remote methods are not supported')
}
const methodName = method.name.text
const exportedMethod = invocation.exportName ?? methodName
const lookups = this.lookupDeclarations()
const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup]))
const parameters: InvocationParameterModel[] = []
const wires = new Set<string>()
for (const parameter of method.parameters) {
if (!ts.isIdentifier(parameter.name)) {
this.fail(parameter, 'Remote parameters must use identifier bindings')
}
if (parameter.dotDotDotToken !== undefined) this.fail(parameter, 'Remote parameters cannot be rest parameters')
if (parameter.initializer !== undefined) this.fail(parameter, 'Remote parameters cannot have default values')
if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional')
if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter')
const authoredType = this.requiredType(parameter, parameter.type, 'parameter')
const hostSymbol = this.symbolAtType(authoredType)
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
let modeled: InvocationParameterModel
if (lookup !== undefined) {
if (parameter.name.text !== lookup.key) {
this.fail(parameter, `lookup parameter for ${lookup.key} must also be named ${lookup.key}`)
}
const boundary = this.remoteBoundary(
lookup.wireType,
`${registration.name}#${binding.namespace}/${exportedMethod}:${lookup.key}Id`,
true,
)
modeled = {
name: parameter.name.text,
wire: `${lookup.key}Id`,
source: 'lookup',
lookup: lookup.key,
boundary,
}
} else {
if (hostSymbol !== undefined && this.isWorkspaceClass(hostSymbol)) {
this.fail(parameter, `non-JSON class parameter ${hostSymbol.name} requires a TypeRTLookupMap entry`)
}
modeled = {
name: parameter.name.text,
wire: parameter.name.text,
source: 'json',
boundary: this.remoteBoundary(
authoredType,
`${registration.name}#${binding.namespace}/${exportedMethod}:${parameter.name.text}`,
false,
),
}
}
if (wires.has(modeled.wire)) this.fail(parameter, `duplicate Remote wire field ${modeled.wire}`)
wires.add(modeled.wire)
parameters.push(modeled)
}
let receiver: InvocationModel['invocation'] = { kind: 'direct' }
if (invocation.kind === 'context') {
const context = this.contextDeclarations().get(invocation.context)
if (context === undefined) {
this.fail(method, `Remote Context ${invocation.context} has no TypeRTContextMap entry`)
}
const wire = `${invocation.context}Id`
if (wires.has(wire)) this.fail(method, `Remote Context wire field ${wire} conflicts with a method parameter`)
receiver = {
kind: 'context',
context: invocation.context,
wire,
boundary: this.remoteBoundary(
context.wireType,
`${registration.name}#${binding.namespace}/${exportedMethod}:${wire}`,
true,
),
}
}
let scope: InvocationModel['scope']
if (invocation.kind === 'direct') {
const lookupParameters = parameters.filter(parameter => parameter.source === 'lookup')
const parameter = lookupParameters.length === 1 ? lookupParameters[0] : undefined
const context = parameter?.lookup === undefined
? undefined
: this.contextDeclarations().get(parameter.lookup)
if (parameter !== undefined && context !== undefined) {
const contextBoundary = this.remoteBoundary(
context.wireType,
`${registration.name}#${binding.namespace}/${exportedMethod}:scope:${context.key}`,
true,
)
if (contextBoundary.typeSymbol !== parameter.boundary.typeSymbol) {
this.fail(
method,
`Remote scope ${context.key} wire type ${contextBoundary.typeSymbol} does not match lookup wire type ${parameter.boundary.typeSymbol}`,
)
}
scope = { context: context.key, wire: parameter.wire }
}
}
const resultType = this.remoteResultType(method)
return {
id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
service: binding.service,
namespace: binding.namespace,
method: exportedMethod,
...(exportedMethod === methodName ? {} : { implementation: methodName }),
invocation: receiver,
...(scope === undefined ? {} : { scope }),
parameters,
result: this.remoteBoundary(
resultType,
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
false,
),
location: this.location(method.name),
}
}
private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration =>
ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway')
const [property, duplicate] = candidates
if (property === undefined) return undefined
if (duplicate !== undefined) this.fail(duplicate, 'Service has more than one typertGateway field')
if (visibilityOf(property) !== 'public'
|| hasModifier(property, ts.SyntaxKind.StaticKeyword)
|| !hasModifier(property, ts.SyntaxKind.ReadonlyKeyword)) {
this.fail(property, 'typertGateway must be a public readonly instance field')
}
if (property.initializer === undefined
|| !ts.isCallExpression(property.initializer)
|| !this.isTypeMetaSymbol(property.initializer.expression, 'bindTypeRTGateway')) {
this.fail(property, 'typertGateway must call bindTypeRTGateway()')
}
const call = property.initializer
if (call.arguments.length < 2 || call.arguments.length > 3) {
this.fail(call, 'bindTypeRTGateway() requires this, service key, and an optional options object')
}
if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) {
this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this')
}
const serviceArgument = call.arguments[1]
if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal')
const service = stringLiteralValue(serviceArgument)
if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal')
let namespace = service
const options = call.arguments[2]
if (options !== undefined) {
if (!ts.isObjectLiteralExpression(options)) {
this.fail(options, 'bindTypeRTGateway() options must be an object literal')
}
for (const propertyOption of options.properties) {
if (!ts.isPropertyAssignment(propertyOption)
|| memberName(propertyOption.name) !== 'namespace') {
this.fail(propertyOption, 'bindTypeRTGateway() only supports a namespace option')
}
const value = stringLiteralValue(propertyOption.initializer)
if (value === undefined) this.fail(propertyOption.initializer, 'Gateway namespace must be a string literal')
namespace = value
}
}
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"')
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"')
return { service, namespace, site: property }
}
private remoteMarker(
member: ts.ClassElement,
):
| { readonly kind: 'direct'; readonly exportName?: string }
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
| undefined {
let found:
| { readonly kind: 'direct'; readonly exportName?: string }
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
| undefined
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
const expression = decorator.expression
let marker: typeof found
if (this.isTypeMetaSymbol(expression, 'Remote')) {
marker = { kind: 'direct' }
} else if (ts.isCallExpression(expression)
&& this.isTypeMetaSymbol(expression.expression, 'Remote')) {
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
const exportName = stringLiteralValue(expression.arguments[0])
if (exportName === undefined || !isRemoteSegment(exportName)) {
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"')
}
marker = { kind: 'direct', exportName }
} else if (ts.isCallExpression(expression)
&& this.isTypeMetaSymbol(expression.expression, 'RemoteContext')) {
if (expression.arguments.length < 1 || expression.arguments.length > 2) {
this.fail(expression, 'RemoteContext() requires a Context key and optional exported method name')
}
const context = stringLiteralValue(expression.arguments[0])
if (context === undefined || !isRemoteSegment(context)) {
this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"')
}
const exportArgument = expression.arguments[1]
const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument)
if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) {
this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"')
}
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
} else {
continue
}
if (found !== undefined) this.fail(decorator, 'a method can have only one Remote invocation decorator')
found = marker
}
return found
}
private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode {
const authored = this.requiredType(method, method.type, 'return')
if (!ts.isTypeReferenceNode(authored)) return authored
const symbol = this.checker.getSymbolAtLocation(authored.typeName)
const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
const resultType = authored.typeArguments?.[0]
if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored
const declaration = preferredDeclaration(resolved)
if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored
return resultType
}
private lookupDeclarations(): readonly StaticLookupDeclaration[] {
if (this.staticLookups !== undefined) return this.staticLookups
const byKey = new Map<string, StaticLookupDeclaration>()
const byHost = new Map<SymbolId, StaticLookupDeclaration>()
for (const declaration of this.typeMetaMapMembers('TypeRTLookupMap')) {
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
this.fail(declaration, 'TypeRTLookupMap entries must be required properties')
}
const key = memberName(declaration.name)
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"')
if (!ts.isTypeReferenceNode(declaration.type)
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|| declaration.type.typeArguments?.length !== 2) {
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
}
const hostType = declaration.type.typeArguments[0]
const wireType = declaration.type.typeArguments[1]
if (hostType === undefined || wireType === undefined) {
this.fail(declaration.type, 'TypeRTLookupMap values must be TypeRTLookup<Host, Wire>')
}
const host = this.symbolAtType(hostType)
if (host === undefined) this.fail(hostType, 'TypeRTLookup Host must be a named type')
const entry: StaticLookupDeclaration = {
key,
hostSymbol: this.symbolId(host),
wireType,
site: declaration,
}
if (byKey.has(key)) this.fail(declaration, `duplicate TypeRTLookupMap key ${key}`)
if (byHost.has(entry.hostSymbol)) this.fail(declaration, `Host type ${host.name} has more than one TypeRT lookup`)
byKey.set(key, entry)
byHost.set(entry.hostSymbol, entry)
}
this.staticLookups = [...byKey.values()]
return this.staticLookups
}
private contextDeclarations(): ReadonlyMap<string, StaticContextDeclaration> {
if (this.staticContexts !== undefined) return this.staticContexts
const result = new Map<string, StaticContextDeclaration>()
for (const declaration of this.typeMetaMapMembers('TypeRTContextMap')) {
if (!ts.isPropertySignature(declaration) || declaration.type === undefined) {
this.fail(declaration, 'TypeRTContextMap entries must be required properties')
}
const key = memberName(declaration.name)
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"')
if (!ts.isTypeReferenceNode(declaration.type)
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|| declaration.type.typeArguments?.length !== 1) {
this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
}
if (result.has(key)) this.fail(declaration, `duplicate TypeRTContextMap key ${key}`)
const wireType = declaration.type.typeArguments[0]
if (wireType === undefined) this.fail(declaration.type, 'TypeRTContextMap values must be TypeRTContext<Wire>')
result.set(key, {
key,
wireType,
site: declaration,
})
}
this.staticContexts = result
return result
}
private typeMetaMapMembers(name: 'TypeRTLookupMap' | 'TypeRTContextMap'): ts.TypeElement[] {
const result: ts.TypeElement[] = []
for (const sourceFile of this.program.getSourceFiles()) {
for (const statement of sourceFile.statements) {
if (!ts.isModuleDeclaration(statement)
|| !ts.isStringLiteral(statement.name)
|| statement.name.text !== '@deepseek-ai/dsh-type-meta'
|| statement.body === undefined
|| !ts.isModuleBlock(statement.body)) continue
for (const nested of statement.body.statements) {
if (ts.isInterfaceDeclaration(nested) && nested.name.text === name) result.push(...nested.members)
}
}
}
return result
}
private remoteBoundary(
authoredType: ts.TypeNode,
fallbackTypeSymbol: string,
requireNamed: boolean,
): RemoteBoundaryModel {
const type = this.convertType(authoredType)
const codecType = this.resolvedRemoteCodecType(authoredType)
const rootSymbol = this.namedWorkspaceType(authoredType)
if (rootSymbol !== undefined) {
const imported = this.publicRemoteType(rootSymbol, authoredType)
return {
type,
codecType,
typeSymbol: `${imported.specifier}#${imported.name}`,
imports: [imported],
}
}
if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types')
const imports = new Map<SymbolId, RemoteTypeImportModel>()
const visit = (node: ts.Node): void => {
if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) {
const symbol = ts.isTypeReferenceNode(node)
? this.checker.getSymbolAtLocation(node.typeName)
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
if (symbol !== undefined) {
const resolved = this.resolveSymbol(symbol)
const declaration = preferredDeclaration(resolved)
if (declaration !== undefined
&& !isStandardLibraryFile(declaration.getSourceFile().fileName)
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
const imported = this.publicRemoteType(resolved, node)
imports.set(imported.symbol, imported)
return
}
}
}
ts.forEachChild(node, visit)
}
visit(authoredType)
return {
type,
codecType,
typeSymbol: fallbackTypeSymbol,
imports: [...imports.values()].sort((left, right) =>
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
}
}
/**
* Project one authored Remote boundary through the complete face Program.
* Consumer declarations retain the authored alias, while codecs use this
* concrete graph so declaration-merged mapped and conditional types are
* validated without teaching the compiler-independent emitter TypeScript's
* type evaluator.
*/
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
const completed = new Map<ts.Type, TypeNodeId>()
const active = new Map<ts.Type, TypeNodeId>()
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
const convert = (type: ts.Type): TypeNodeId => {
const cached = completed.get(type)
if (cached !== undefined) return cached
const activeId = active.get(type)
if (activeId !== undefined) {
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
const elementId = element === undefined ? undefined : active.get(element)
if (element !== undefined && elementId !== undefined) {
return this.addNode(authoredType, {
kind: 'array',
element: this.resolvedCycleReference(
element,
authoredType,
elementId,
recursiveDeclarations,
),
})
}
}
return this.resolvedCycleReference(type, authoredType, activeId, recursiveDeclarations)
}
const id = this.allocateNodeId(authoredType)
active.set(type, id)
try {
const add = (model: TypeNodeInput): TypeNodeId => {
this.nodes.set(id, { id, ...model })
completed.set(type, id)
return id
}
const flags = type.flags
if ((flags & ts.TypeFlags.Any) !== 0) return add({ kind: 'keyword', name: 'any' })
if ((flags & ts.TypeFlags.Unknown) !== 0) return add({ kind: 'keyword', name: 'unknown' })
if ((flags & ts.TypeFlags.Never) !== 0) return add({ kind: 'keyword', name: 'never' })
if ((flags & ts.TypeFlags.String) !== 0) return add({ kind: 'keyword', name: 'string' })
if ((flags & ts.TypeFlags.Number) !== 0) return add({ kind: 'keyword', name: 'number' })
if ((flags & ts.TypeFlags.BigInt) !== 0) return add({ kind: 'keyword', name: 'bigint' })
if ((flags & ts.TypeFlags.Boolean) !== 0) return add({ kind: 'keyword', name: 'boolean' })
if ((flags & ts.TypeFlags.ESSymbol) !== 0) return add({ kind: 'keyword', name: 'symbol' })
if ((flags & ts.TypeFlags.Undefined) !== 0) return add({ kind: 'keyword', name: 'undefined' })
if ((flags & ts.TypeFlags.Void) !== 0) return add({ kind: 'keyword', name: 'void' })
if ((flags & ts.TypeFlags.Null) !== 0) return add({ kind: 'literal', value: null, text: 'null' })
if ((flags & ts.TypeFlags.StringLiteral) !== 0) {
const value = (type as ts.StringLiteralType).value
return add({ kind: 'literal', value, text: JSON.stringify(value) })
}
if ((flags & ts.TypeFlags.NumberLiteral) !== 0) {
const value = (type as ts.NumberLiteralType).value
return add({ kind: 'literal', value, text: String(value) })
}
if ((flags & ts.TypeFlags.BigIntLiteral) !== 0) {
const value = (type as ts.BigIntLiteralType).value
const text = `${value.negative ? '-' : ''}${value.base10Value}n`
return add({ kind: 'literal', value: BigInt(`${value.negative ? '-' : ''}${value.base10Value}`), text })
}
if ((flags & ts.TypeFlags.BooleanLiteral) !== 0) {
const value = (type as ts.Type & { readonly intrinsicName?: string }).intrinsicName === 'true'
return add({ kind: 'literal', value, text: String(value) })
}
if (type.isUnionOrIntersection()) {
return add({
kind: (flags & ts.TypeFlags.Union) !== 0 ? 'union' : 'intersection',
types: type.types.map(convert),
})
}
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
this.fail(authoredType, 'Remote codec contains an unresolved type parameter')
}
if ((flags & ts.TypeFlags.Object) === 0) {
this.fail(
authoredType,
`Remote codec type ${this.checker.typeToString(type, authoredType, ts.TypeFormatFlags.NoTruncation)} has no concrete Zod projection`,
)
}
if (this.checker.isTupleType(type)) {
const reference = type as ts.TypeReference
const target = reference.target as ts.TupleType
const arguments_ = this.checker.getTypeArguments(reference)
return add({
kind: 'tuple',
elements: arguments_.map((argument, index) => {
const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required
return {
type: convert(argument),
optional: (elementFlags & ts.ElementFlags.Optional) !== 0,
rest: (elementFlags & (ts.ElementFlags.Rest | ts.ElementFlags.Variadic)) !== 0,
}
}),
})
}
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
if (element === undefined) this.fail(authoredType, 'Remote codec array has no element type')
return add({ kind: 'array', element: convert(element) })
}
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
this.fail(authoredType, 'Remote codec cannot contain callable or constructable values')
}
const members: MemberModel[] = []
for (const property of this.checker.getPropertiesOfType(type)) {
const declaration = property.valueDeclaration ?? property.declarations?.[0]
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration ?? authoredType)
const symbolKey = property.getName()
members.push({
...EMPTY_DOCUMENTATION,
id: `${id}#${symbolKey}`,
name: symbolKey,
...(symbolKey.startsWith('__@') ? { computed: 'symbol' as const } : {}),
optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
readonly: declaration !== undefined && hasModifier(declaration, ts.SyntaxKind.ReadonlyKeyword),
async: false,
abstract: false,
static: false,
visibility: 'public',
location: this.location(authoredType),
text: '',
kind: 'property',
type: convert(propertyType),
})
}
for (const [index, info] of this.checker.getIndexInfosOfType(type).entries()) {
members.push({
...EMPTY_DOCUMENTATION,
id: `${id}#index:${String(index)}`,
name: '(index)',
optional: false,
readonly: info.isReadonly,
async: false,
abstract: false,
static: false,
visibility: 'public',
location: this.location(authoredType),
text: '',
kind: 'index',
signature: {
typeParameters: [],
parameters: [{
name: 'key',
binding: 'identifier',
type: convert(info.keyType),
optional: false,
rest: false,
receiver: false,
}],
returns: convert(info.type),
},
})
}
return add({ kind: 'object', members })
} finally {
active.delete(type)
}
}
return convert(this.checker.getTypeFromTypeNode(authoredType))
}
private resolvedCycleReference(
type: ts.Type,
site: ts.TypeNode,
resolvedType: TypeNodeId,
recursiveDeclarations: Map<ts.Type, SymbolId>,
): TypeNodeId {
const symbol = type.aliasSymbol ?? type.getSymbol()
if (symbol === undefined) this.fail(site, 'Remote codec contains an unnamed recursive type')
const resolved = this.resolveSymbol(symbol)
const declaration = preferredDeclaration(resolved)
if (declaration === undefined || isStandardLibraryFile(declaration.getSourceFile().fileName)) {
this.fail(site, `Remote codec recursive type ${resolved.name} has no workspace declaration`)
}
const owner = this.registrationForFile(declaration.getSourceFile().fileName)
if (owner === undefined) this.fail(site, `Remote codec recursive type ${resolved.name} is not owned by this face`)
let id = recursiveDeclarations.get(type)
if (id === undefined) {
id = `${this.symbolId(resolved)}#remote-codec:${resolvedType}`
recursiveDeclarations.set(type, id)
this.declarations.set(id, {
...EMPTY_DOCUMENTATION,
id,
package: owner.name,
name: `${resolved.name}RemoteCodec`,
kind: 'alias',
abstract: false,
exported: false,
location: this.location(declaration),
text: '',
typeParameters: [],
extends: [],
implements: [],
members: [],
type: resolvedType,
})
}
return this.addNode(site, {
kind: 'reference',
name: `${resolved.name}RemoteCodec`,
target: { kind: 'declaration', symbol: id },
arguments: [],
})
}
private namedWorkspaceType(node: ts.TypeNode): ts.Symbol | undefined {
if (!ts.isTypeReferenceNode(node) && !ts.isImportTypeNode(node)) return undefined
const symbol = ts.isTypeReferenceNode(node)
? this.checker.getSymbolAtLocation(node.typeName)
: node.qualifier === undefined ? undefined : this.checker.getSymbolAtLocation(node.qualifier)
if (symbol === undefined) return undefined
const resolved = this.resolveSymbol(symbol)
const declaration = preferredDeclaration(resolved)
if (declaration === undefined
|| isStandardLibraryFile(declaration.getSourceFile().fileName)
|| this.registrationForFile(declaration.getSourceFile().fileName) === undefined) return undefined
return resolved
}
private publicRemoteType(symbol: ts.Symbol, site: ts.Node): RemoteTypeImportModel {
const declaration = preferredDeclaration(symbol)
if (declaration === undefined) this.fail(site, `type ${symbol.name} has no declaration`)
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
if (registration === undefined) this.fail(site, `type ${symbol.name} is not owned by a workspace package`)
const candidates: RemoteTypeImportModel[] = []
for (const [subpath, target] of packageExportTargets(registration.manifest)) {
if (subpath === '.' || subpath === './package.json' || subpath === './typert'
|| subpath === './client/typert' || subpath === './remote' || target.includes('*')) continue
const sourceFile = this.sourceFiles.get(realPath(sourcePathForExport(registration.root, target)))
if (sourceFile === undefined) continue
const moduleSymbol = this.checker.getSymbolAtLocation(sourceFile)
if (moduleSymbol === undefined) continue
for (const exported of this.checker.getExportsOfModule(moduleSymbol)) {
if (this.resolveSymbol(exported) !== symbol) continue
candidates.push({
symbol: this.symbolId(symbol),
specifier: packageExportSpecifier(registration.name, subpath),
name: exported.name,
})
}
}
const selected = candidates.sort((left, right) =>
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))[0]
if (selected === undefined) {
this.fail(site, `Remote boundary type ${symbol.name} must be exported from a public non-root type subpath`)
}
return selected
}
private isWorkspaceClass(symbol: ts.Symbol): boolean {
const declaration = preferredDeclaration(symbol)
return declaration !== undefined
&& ts.isClassDeclaration(declaration)
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined
}
private isTypeMetaSymbol(node: ts.Node, name: string): boolean {
const symbol = this.checker.getSymbolAtLocation(node)
if (symbol === undefined) return false
const resolved = this.resolveSymbol(symbol)
if (resolved.name !== name) return false
const declaration = preferredDeclaration(resolved)
if (declaration === undefined) return false
const registration = this.registrationForFile(declaration.getSourceFile().fileName)
if (registration?.name === '@deepseek-ai/dsh-type-meta') return true
for (let current: ts.Node | undefined = declaration; current !== undefined; current = optionalParent(current)) {
if (ts.isModuleDeclaration(current)
&& ts.isStringLiteral(current.name)
&& current.name.text === '@deepseek-ai/dsh-type-meta') return true
}
return false
}
private validateInvocationIdentity(packages: readonly PackageModel[]): void {
const endpoints = new Map<string, InvocationModel>()
const ids = new Map<string, InvocationModel>()
for (const invocation of packages.flatMap(packageModel => packageModel.invocations)) {
const endpoint = `${invocation.namespace}/${invocation.method}`
const existingEndpoint = endpoints.get(endpoint)
if (existingEndpoint !== undefined) {
throw new TypertAnalysisError(
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote endpoint ${endpoint} conflicts with ${existingEndpoint.id}`,
)
}
const existingId = ids.get(invocation.id)
if (existingId !== undefined) {
throw new TypertAnalysisError(
`typert(${this.face}): ${invocation.location.file}:${String(invocation.location.line)}:${String(invocation.location.column)}: Remote invocation id ${invocation.id} conflicts with ${existingId.id}`,
)
}
endpoints.set(endpoint, invocation)
ids.set(invocation.id, invocation)
}
}
private collectEvents(events: ts.InterfaceDeclaration): EventModel[] {
const result: EventModel[] = []
for (const member of events.members) {
@@ -1015,6 +1777,11 @@ class FaceAnalyzer {
): MemberModel[] {
const result: MemberModel[] = []
for (const member of members) {
if (ts.isPropertyDeclaration(member)
&& memberName(member.name) === 'typertGateway'
&& member.initializer !== undefined
&& ts.isCallExpression(member.initializer)
&& this.isTypeMetaSymbol(member.initializer.expression, 'bindTypeRTGateway')) continue
const visibility = visibilityOf(member)
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue
@@ -1045,17 +1812,19 @@ class FaceAnalyzer {
visibility: MemberVisibility,
isStatic: boolean,
): MemberBase {
const name = member.name !== undefined
? memberName(member.name)
: ts.isCallSignatureDeclaration(member)
? '(call)'
: ts.isConstructSignatureDeclaration(member)
? '(construct)'
: '(index)'
const identity = member.name !== undefined
? this.memberIdentity(member.name)
: {
name: ts.isCallSignatureDeclaration(member)
? '(call)'
: ts.isConstructSignatureDeclaration(member)
? '(construct)'
: '(index)',
}
return {
...documentationOf(member),
id: `${ownerId}#${name}@${String(member.getStart())}`,
name,
id: `${ownerId}#${identity.name}@${String(member.getStart())}`,
...identity,
optional: 'questionToken' in member && member.questionToken !== undefined,
readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword),
async: hasModifier(member, ts.SyntaxKind.AsyncKeyword),
@@ -1067,6 +1836,20 @@ class FaceAnalyzer {
}
}
private memberIdentity(name: ts.PropertyName): Pick<MemberBase, 'name' | 'jsonName' | 'computed'> {
if (!ts.isComputedPropertyName(name)) return { name: memberName(name) }
const expression = name.expression
if (ts.isStringLiteral(expression) || ts.isNumericLiteral(expression)
|| ts.isNoSubstitutionTemplateLiteral(expression)) {
return { name: memberName(name), jsonName: expression.text }
}
const type = this.checker.getTypeAtLocation(expression)
return {
name: memberName(name),
computed: (type.flags & ts.TypeFlags.UniqueESSymbol) !== 0 ? 'symbol' : 'dynamic',
}
}
private signature(
node: ts.SignatureDeclarationBase,
explicitReturn: ts.TypeNode | undefined,
@@ -1570,7 +2353,23 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean {
|| ts.isInterfaceDeclaration(statement)
|| ts.isTypeAliasDeclaration(statement)
|| ts.isEnumDeclaration(statement))
&& typertMode(statement) !== undefined) return true
&& (typertMode(statement) !== undefined || typertServiceTag(statement) !== undefined)) return true
if (ts.isClassDeclaration(statement)) {
for (const member of statement.members) {
if (ts.isPropertyDeclaration(member)
&& memberName(member.name) === 'typertGateway'
&& member.initializer !== undefined
&& ts.isCallExpression(member.initializer)
&& expressionName(member.initializer.expression) === 'bindTypeRTGateway') return true
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
const expression = ts.isCallExpression(decorator.expression)
? decorator.expression.expression
: decorator.expression
const name = expressionName(expression)
if (name === 'Remote' || name === 'RemoteContext') return true
}
}
}
if (!ts.isModuleDeclaration(statement)
|| !ts.isStringLiteral(statement.name)
|| statement.name.text !== 'cordis'
@@ -1588,6 +2387,7 @@ function hasPackageSurface(model: PackageModel): boolean {
|| model.events.length > 0
|| model.objects.length > 0
|| model.schemas.length > 0
|| model.invocations.length > 0
}
function isDualFacePackage(manifest: Record<string, unknown>): boolean {
@@ -1599,7 +2399,9 @@ function isDualFacePackage(manifest: Record<string, unknown>): boolean {
function hostExportSubpaths(manifest: Record<string, unknown>): string[] {
return packageExportTargets(manifest)
.map(([subpath]) => subpath)
.filter(subpath => subpath !== './client' && !subpath.startsWith('./client/'))
.filter(subpath => subpath !== './client'
&& !subpath.startsWith('./client/')
&& subpath !== './remote')
}
function clientExportSubpaths(manifest: Record<string, unknown>): string[] {
@@ -1668,6 +2470,10 @@ function preferredDeclaration(symbol: ts.Symbol): ts.Declaration | undefined {
?? symbol.declarations?.[0]
}
function optionalParent(node: ts.Node): ts.Node | undefined {
return (node as ts.Node & { readonly parent?: ts.Node }).parent
}
function isTypeDeclaration(
node: ts.Node,
): node is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.EnumDeclaration {
@@ -1822,6 +2628,11 @@ function typertMode(node: ts.Node): 'object' | 'schema' | undefined {
return undefined
}
function typertServiceTag(node: ts.Node): ts.JSDocTag | undefined {
return ts.getJSDocTags(node).find(tag => tag.tagName.text === 'typert'
&& (ts.getTextOfJSDocComment(tag.comment) ?? '').trim().split(/\s+/, 1)[0] === 'service')
}
function memberName(name: ts.PropertyName | ts.BindingName): string {
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name)
|| ts.isNumericLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text
@@ -1829,6 +2640,26 @@ function memberName(name: ts.PropertyName | ts.BindingName): string {
return name.getText()
}
function stringLiteralValue(node: ts.Node | undefined): string | undefined {
return node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
? node.text
: undefined
}
function isRemoteSegment(value: string): boolean {
return value.length > 0 && !value.includes('/')
}
function expressionName(node: ts.Expression): string | undefined {
if (ts.isIdentifier(node)) return node.text
if (ts.isPropertyAccessExpression(node)) return node.name.text
return undefined
}
function packageExportSpecifier(packageName: string, subpath: string): string {
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
}
function visibilityOf(node: ts.Node): MemberVisibility {
if ('name' in node && node.name !== undefined && ts.isPrivateIdentifier(node.name as ts.Node)) return 'private'
if (hasModifier(node, ts.SyntaxKind.PrivateKeyword)) return 'private'

View File

@@ -231,7 +231,7 @@ export class CordisCatalogProjector {
for (const service of packageModel.services) {
const declaration = this.renderer.declaration(service.symbol)
if (declaration.kind !== 'class'
|| !/^packages\/[^/]+\/[^/]+\/src\/index\.ts$/.test(service.location.file)
|| !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
|| declaration.location.file !== service.location.file) continue
const doc = parseJsDoc(declaration.jsDoc ?? '').doc
const source = pointer(declaration.location)

View File

@@ -4,11 +4,17 @@
* @module @deepseek-ai/dsh-typert-generator/emitter
*/
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import { GenMapping, addMapping, toEncodedMap } from '@jridgewell/gen-mapping'
import type {
DocumentationModel,
FaceModel,
InvocationModel,
MemberModel,
PackageModel,
RemoteBoundaryModel,
RemoteTypeImportModel,
SchemaModel,
SymbolId,
TypeDeclarationModel,
@@ -29,6 +35,14 @@ export interface ModelEmitResult {
readonly exports: readonly string[]
readonly js: string
readonly dts: string
readonly remote?: RemoteModelEmitResult
}
/** Host-for-Client Remote contribution generated from the Host Program. */
export interface RemoteModelEmitResult {
readonly js: string
readonly dts: string
readonly dtsMap: string
}
interface RuntimeMemberModel {
@@ -92,7 +106,11 @@ export class FaceModelEmitter {
if (packageModel === undefined) {
throw new TypertEmitError(`typert emitter(${this.face.face}): package ${packageName} is not modeled on this face`)
}
const schemas = new SchemaEmitter(this.renderer, packageModel.schemas)
const schemas = new SchemaEmitter(
this.renderer,
packageModel.schemas,
invocationBoundaryRoots(packageModel.invocations),
)
const schemaArtifact = schemas.emit()
const runtimeModel = this.runtimeModel(packageModel)
const js = this.renderJs(packageModel, schemaArtifact, runtimeModel)
@@ -103,6 +121,9 @@ export class FaceModelEmitter {
exports: packageModel.schemas.map(schema => schema.export.name),
js,
dts,
...(this.face.face === 'host' && packageModel.invocations.length > 0
? { remote: this.emitRemote(packageModel) }
: {}),
}
}
@@ -184,6 +205,11 @@ export class FaceModelEmitter {
lines.push(` { name: ${quote(schema.exportName)}, schema: ${schema.exportName} },`)
}
lines.push(' ],')
lines.push(' invocations: [')
for (const invocation of packageModel.invocations) {
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
}
lines.push(' ],')
lines.push(` model: ${indent(model, 2).trimStart()},`)
lines.push('}')
return `${lines.join('\n')}\n`
@@ -215,6 +241,246 @@ export class FaceModelEmitter {
lines.push('export declare const TYPERT: unknown')
return `${lines.join('\n')}\n`
}
private emitRemote(packageModel: PackageModel): RemoteModelEmitResult {
const schemas = new SchemaEmitter(
this.renderer,
[],
invocationBoundaryRoots(packageModel.invocations),
).emit()
const lines = [
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
]
if (schemas.definitions.length > 0) lines.push('import { z } from \'zod\'', '')
lines.push(...schemas.definitions)
if (schemas.definitions.length > 0) lines.push('')
lines.push('export const TYPERT_REMOTE = {')
lines.push(` package: ${quote(packageModel.name)},`)
lines.push(' descriptors: [')
for (const invocation of packageModel.invocations) {
lines.push(`${indent(this.invocationLiteral(invocation, schemas), 4)},`)
}
lines.push(' ],')
lines.push('}')
lines.push('')
lines.push('export default TYPERT_REMOTE')
const declaration = this.renderRemoteDts(packageModel)
return {
js: `${lines.join('\n')}\n`,
...declaration,
}
}
private invocationLiteral(invocation: InvocationModel, schemas: SchemaArtifact): string {
const lines = [
'{',
` id: ${quote(invocation.id)},`,
` service: ${quote(invocation.service)},`,
` namespace: ${quote(invocation.namespace)},`,
` method: ${quote(invocation.method)},`,
]
if (invocation.implementation !== undefined) {
lines.push(` implementation: ${quote(invocation.implementation)},`)
}
if (invocation.invocation.kind === 'direct') {
lines.push(' invocation: { kind: \'direct\' },')
} else {
lines.push(' invocation: {')
lines.push(' kind: \'context\',')
lines.push(` context: ${quote(invocation.invocation.context)},`)
lines.push(` wire: ${quote(invocation.invocation.wire)},`)
lines.push(` codec: ${indent(strictCodec(
invocation.invocation.boundary,
schemas.boundary(contextBoundaryKey(invocation)),
), 4).trimStart()},`)
lines.push(' },')
}
if (invocation.scope !== undefined) {
lines.push(' scope: {')
lines.push(` context: ${quote(invocation.scope.context)},`)
lines.push(` wire: ${quote(invocation.scope.wire)},`)
lines.push(' },')
}
lines.push(' parameters: [')
invocation.parameters.forEach((parameter, index) => {
lines.push(' {')
lines.push(` name: ${quote(parameter.name)},`)
lines.push(` wire: ${quote(parameter.wire)},`)
lines.push(` source: ${quote(parameter.source)},`)
if (parameter.lookup !== undefined) lines.push(` lookup: ${quote(parameter.lookup)},`)
lines.push(` codec: ${indent(strictCodec(
parameter.boundary,
schemas.boundary(parameterBoundaryKey(invocation, index)),
), 6).trimStart()},`)
lines.push(' },')
})
lines.push(' ],')
lines.push(` result: ${indent(strictCodec(
invocation.result,
schemas.boundary(resultBoundaryKey(invocation)),
), 2).trimStart()},`)
lines.push(` sourceLocation: ${JSON.stringify(invocation.location)},`)
lines.push('}')
return lines.join('\n')
}
private renderRemoteDts(packageModel: PackageModel): Pick<RemoteModelEmitResult, 'dts' | 'dtsMap'> {
const imports = remoteImports(packageModel.invocations)
const referenceNames = allocateRemoteImportNames(imports)
const grouped = new Map<string, { readonly name: string; readonly local: string }[]>()
for (const imported of imports) {
const values = grouped.get(imported.specifier) ?? []
values.push({
name: imported.name,
local: referenceNames.get(imported.symbol) as string,
})
grouped.set(imported.specifier, values)
}
const lines = [
'/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */',
'import type {',
' TypeRTRemoteContribution,',
'} from \'@deepseek-ai/dsh-type-meta\'',
]
const sourceMap = new GenMapping({ file: 'typert.remote-client.d.ts' })
for (const [specifier, values] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) {
const names = values.sort((left, right) => left.local.localeCompare(right.local)).map(value =>
value.name === value.local ? value.name : `${value.name} as ${value.local}`)
lines.push(`import type { ${names.join(', ')} } from ${quote(specifier)}`)
}
lines.push('')
lines.push('declare module \'@deepseek-ai/dsh-type-meta\' {')
const direct = packageModel.invocations.filter(invocation => invocation.invocation.kind === 'direct')
const scoped = packageModel.invocations.filter(invocation =>
invocation.invocation.kind === 'context' || invocation.scope !== undefined)
if (direct.length > 0) {
for (const namespace of uniqueNamespaces(direct)) {
lines.push(` interface ${remoteNamespaceInterface(namespace)} {`)
for (const invocation of direct.filter(candidate => candidate.namespace === namespace)) {
this.pushRemoteNamespaceSignature(lines, sourceMap, packageModel, invocation, referenceNames)
}
lines.push(' }')
}
lines.push(' interface TypeRTRemoteMap {')
for (const invocation of direct) {
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, false)
}
lines.push(' }')
lines.push(' interface TypeRTRemoteNamespaceMap {')
for (const namespace of uniqueNamespaces(direct)) {
lines.push(` ${quote(namespace)}: ${remoteNamespaceInterface(namespace)}`)
}
lines.push(' }')
}
if (scoped.length > 0) {
lines.push(' interface TypeRTRemoteContextMap {')
for (const invocation of scoped) {
this.pushRemoteSignature(lines, sourceMap, packageModel, invocation, referenceNames, true)
}
lines.push(' }')
}
lines.push('}')
lines.push('')
lines.push('export declare const TYPERT_REMOTE: TypeRTRemoteContribution')
lines.push('export default TYPERT_REMOTE')
lines.push('//# sourceMappingURL=typert.remote-client.d.ts.map')
return {
dts: `${lines.join('\n')}\n`,
dtsMap: `${JSON.stringify(toEncodedMap(sourceMap))}\n`,
}
}
private pushRemoteSignature(
lines: string[],
sourceMap: GenMapping,
packageModel: PackageModel,
invocation: InvocationModel,
referenceNames: ReadonlyMap<SymbolId, string>,
scoped: boolean,
): void {
const signature = this.remoteSignature(invocation, referenceNames, scoped)
const line = ` ${signature}`
lines.push(line)
const generatedLine = lines.length
const keyLength = signature.indexOf(': (')
if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`)
const source = remoteDeclarationSource(packageModel, invocation)
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 },
source,
original: { line: invocation.location.line, column: invocation.location.column - 1 },
name: invocation.method,
})
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 + keyLength },
})
}
private pushRemoteNamespaceSignature(
lines: string[],
sourceMap: GenMapping,
packageModel: PackageModel,
invocation: InvocationModel,
referenceNames: ReadonlyMap<SymbolId, string>,
): void {
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
lines.push(` ${signature}`)
const generatedLine = lines.length
const source = remoteDeclarationSource(packageModel, invocation)
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 },
source,
original: { line: invocation.location.line, column: invocation.location.column - 1 },
name: invocation.method,
})
addMapping(sourceMap, {
generated: { line: generatedLine, column: 4 + invocation.method.length },
})
}
private remoteSignature(
invocation: InvocationModel,
referenceNames: ReadonlyMap<SymbolId, string>,
scoped: boolean,
): string {
const context = invocation.invocation.kind === 'context'
? invocation.invocation.context
: invocation.scope?.context
const key = scoped
? `${context as string}:${invocation.namespace}/${invocation.method}`
: `${invocation.namespace}/${invocation.method}`
return `${quote(key)}: ${this.remoteFunctionType(invocation, referenceNames, scoped)}`
}
private remoteFunctionType(
invocation: InvocationModel,
referenceNames: ReadonlyMap<SymbolId, string>,
scoped: boolean,
): string {
const parameters = invocation.parameters.filter(parameter =>
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
const result = this.renderer.renderType(invocation.result.type, referenceNames)
return `(${parameters.join(', ')}) => Promise<${result}>`
}
}
function remoteDeclarationSource(packageModel: PackageModel, invocation: InvocationModel): string {
const relativeSource = posix.relative(packageModel.root, invocation.location.file)
if (relativeSource === '' || relativeSource === '..' || relativeSource.startsWith('../') || posix.isAbsolute(relativeSource)) {
throw new TypertEmitError(
`Remote declaration ${invocation.id} is outside its package root ${packageModel.root}`,
)
}
return posix.join('..', relativeSource)
}
function uniqueNamespaces(invocations: readonly InvocationModel[]): string[] {
return [...new Set(invocations.map(invocation => invocation.namespace))].sort()
}
function remoteNamespaceInterface(namespace: string): string {
return `TypeRTRemoteNamespace$${Buffer.from(namespace, 'utf8').toString('hex')}`
}
interface SchemaExport {
@@ -226,15 +492,23 @@ interface SchemaExport {
interface SchemaArtifact {
readonly definitions: readonly string[]
readonly exports: readonly SchemaExport[]
boundary(key: string): string
}
interface BoundarySchemaRoot {
readonly key: string
readonly type: TypeNodeId
}
class SchemaEmitter {
private readonly names = new Map<SymbolId, string>()
private readonly boundaryNames = new Map<string, string>()
private readonly declarations: TypeDeclarationModel[]
constructor(
private readonly renderer: TypeGraphRenderer,
private readonly schemas: readonly SchemaModel[],
private readonly boundaries: readonly BoundarySchemaRoot[],
) {
const declarations = new Map<SymbolId, TypeDeclarationModel>()
for (const schema of schemas) {
@@ -242,6 +516,11 @@ class SchemaEmitter {
declarations.set(declaration.id, declaration)
}
}
for (const boundary of boundaries) {
for (const declaration of renderer.declarationClosureForTypes([boundary.type])) {
declarations.set(declaration.id, declaration)
}
}
this.declarations = renderer.graph.declarations.filter(declaration => declarations.has(declaration.id))
const identifiers = new Set<string>()
for (const declaration of this.declarations) {
@@ -252,65 +531,92 @@ class SchemaEmitter {
identifiers.add(name)
this.names.set(declaration.id, name)
}
for (const boundary of boundaries) {
const base = `${safeIdentifier(boundary.key)}$schema`
let name = base
let suffix = 2
while (identifiers.has(name)) name = `${base}${String(suffix++)}`
identifiers.add(name)
this.boundaryNames.set(boundary.key, name)
}
}
emit(): SchemaArtifact {
const definitions = this.declarations.map((declaration) => {
if (declaration.typeParameters.length > 0) {
this.fail(declaration.name, 'generic declarations require a schema-factory projection')
}
return `const ${this.schemaName(declaration.id)} = ${this.declarationSchema(declaration)}`
})
const definitions = this.declarations.map(declaration => this.declarationDefinition(declaration))
for (const boundary of this.boundaries) {
definitions.push(`const ${this.boundaryName(boundary.key)} = ${this.typeSchema(boundary.type)}`)
}
const exports = this.schemas.map((model): SchemaExport => ({
model,
exportName: safeIdentifier(model.export.name),
internalName: this.schemaName(model.symbol),
internalName: this.exportSchemaName(model),
}))
return { definitions, exports }
return {
definitions,
exports,
boundary: key => this.boundaryName(key),
}
}
private declarationSchema(declaration: TypeDeclarationModel): string {
private declarationDefinition(declaration: TypeDeclarationModel): string {
const name = this.schemaName(declaration.id)
if (declaration.typeParameters.length === 0) {
return `const ${name} = ${this.declarationSchema(declaration, new Map())}`
}
const parameters = declaration.typeParameters.map((parameter, index) =>
[`type${String(index)}$schema`, parameter.id] as const)
const substitutions = new Map(parameters.map(([schema, id]) => [id, schema]))
return `const ${name} = (${parameters.map(([schema]) => schema).join(', ')}) => ${this.declarationSchema(declaration, substitutions)}`
}
private declarationSchema(
declaration: TypeDeclarationModel,
substitutions: ReadonlyMap<string, string>,
): string {
if (declaration.kind === 'enum') {
this.fail(declaration.name, 'enum declarations have no Zod projection')
}
if (declaration.kind === 'alias') {
if (declaration.type === undefined) this.fail(declaration.name, 'alias has no modeled type')
return this.describe(this.typeSchema(declaration.type), declaration)
return this.describe(this.typeSchema(declaration.type, substitutions), declaration)
}
const own = this.objectSchema(declaration.members, declaration.name)
const own = this.objectSchema(declaration.members, declaration.name, substitutions)
let result = own
for (const heritage of declaration.extends) {
result = `z.intersection(${this.typeSchema(heritage)}, ${result})`
result = `z.intersection(${this.typeSchema(heritage, substitutions)}, ${result})`
}
return this.describe(result, declaration)
}
private typeSchema(id: TypeNodeId): string {
private typeSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string> = new Map()): string {
const node = this.renderer.node(id)
switch (node.kind) {
case 'keyword': return this.keywordSchema(node.name)
case 'literal': return `z.literal(${node.text})`
case 'parenthesized': return this.typeSchema(node.type)
case 'reference': return this.referenceSchema(node)
case 'parenthesized': return this.typeSchema(node.type, substitutions)
case 'reference': return this.referenceSchema(node, substitutions)
case 'union': {
if (node.types.length === 0) return 'z.never()'
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId)
return `z.union([${node.types.map(type => this.typeSchema(type)).join(', ')}])`
if (node.types.length === 1) return this.typeSchema(node.types[0] as TypeNodeId, substitutions)
return `z.union([${node.types.map(type => this.typeSchema(type, substitutions)).join(', ')}])`
}
case 'intersection': {
const [head, ...tail] = node.types
if (head === undefined) return 'z.unknown()'
return tail.reduce((left, right) => `z.intersection(${left}, ${this.typeSchema(right)})`, this.typeSchema(head))
return tail.reduce(
(left, right) => `z.intersection(${left}, ${this.typeSchema(right, substitutions)})`,
this.typeSchema(head, substitutions),
)
}
case 'array': return `z.array(${this.typeSchema(node.element)})`
case 'array': return `z.array(${this.typeSchema(node.element, substitutions)})`
case 'tuple': {
const fixed = node.elements.filter(element => !element.rest)
const rest = node.elements.find(element => element.rest)
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type), element.optional)).join(', ')}])`
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type)})`
let schema = `z.tuple([${fixed.map(element => this.optional(this.typeSchema(element.type, substitutions), element.optional)).join(', ')}])`
if (rest !== undefined) schema += `.rest(${this.tupleRestSchema(rest.type, substitutions)})`
return schema
}
case 'object': return this.objectSchema(node.members, id)
case 'object': return this.objectSchema(node.members, id, substitutions)
case 'operator':
case 'indexed-access':
case 'conditional':
@@ -326,9 +632,27 @@ class SchemaEmitter {
}
}
private referenceSchema(node: Extract<TypeNodeModel, { kind: 'reference' }>): string {
private referenceSchema(
node: Extract<TypeNodeModel, { kind: 'reference' }>,
substitutions: ReadonlyMap<string, string>,
): string {
if (node.target.kind === 'declaration') {
return `z.lazy(() => ${this.schemaName(node.target.symbol)})`
const name = this.schemaName(node.target.symbol)
const declaration = this.renderer.declaration(node.target.symbol)
if (declaration.typeParameters.length === 0) {
if (node.arguments.length > 0) {
this.fail(node.name, `non-generic declaration received ${String(node.arguments.length)} type arguments`)
}
return `z.lazy(() => ${name})`
}
const arguments_ = this.declarationArguments(node, declaration, substitutions)
return `z.lazy(() => ${name}(${arguments_.join(', ')}))`
}
if (node.target.kind === 'type-parameter') {
if (node.arguments.length > 0) this.fail(node.name, 'type parameter reference cannot receive type arguments')
const schema = substitutions.get(node.target.parameter)
if (schema === undefined) this.fail(node.name, 'type parameter has no schema substitution')
return schema
}
if (node.target.kind === 'standard') {
switch (node.target.name) {
@@ -336,13 +660,16 @@ class SchemaEmitter {
case 'ReadonlyArray': {
const element = node.arguments[0]
if (element === undefined) this.fail(node.name, 'array reference has no element type')
return this.readonly(`z.array(${this.typeSchema(element)})`, node.target.name === 'ReadonlyArray')
return this.readonly(
`z.array(${this.typeSchema(element, substitutions)})`,
node.target.name === 'ReadonlyArray',
)
}
case 'Record': {
const key = node.arguments[0]
const value = node.arguments[1]
if (key === undefined || value === undefined) this.fail(node.name, 'Record requires key and value types')
return `z.record(${this.typeSchema(key)}, ${this.typeSchema(value)})`
return `z.record(${this.typeSchema(key, substitutions)}, ${this.typeSchema(value, substitutions)})`
}
case 'Date': return 'z.date()'
default: this.fail(node.name, `standard type ${node.target.name} has no Zod projection`)
@@ -351,31 +678,97 @@ class SchemaEmitter {
this.fail(node.name, `${node.target.kind} reference has no Zod projection`)
}
private tupleRestSchema(id: TypeNodeId): string {
private declarationArguments(
node: Extract<TypeNodeModel, { kind: 'reference' }>,
declaration: TypeDeclarationModel,
substitutions: ReadonlyMap<string, string>,
): string[] {
if (node.arguments.length > declaration.typeParameters.length) {
this.fail(
node.name,
`generic declaration accepts ${String(declaration.typeParameters.length)} type arguments but received ${String(node.arguments.length)}`,
)
}
const resolved = new Map(substitutions)
const arguments_: string[] = []
for (const [index, parameter] of declaration.typeParameters.entries()) {
const argument = node.arguments[index]
const schema = argument === undefined
? parameter.default === undefined
? this.fail(node.name, `missing type argument ${parameter.name}`)
: this.typeSchema(parameter.default, resolved)
: this.typeSchema(argument, substitutions)
arguments_.push(schema)
resolved.set(parameter.id, schema)
}
return arguments_
}
private tupleRestSchema(id: TypeNodeId, substitutions: ReadonlyMap<string, string>): string {
const node = this.renderer.node(id)
if (node.kind === 'array') return this.typeSchema(node.element)
if (node.kind === 'array') return this.typeSchema(node.element, substitutions)
if (node.kind === 'reference'
&& node.target.kind === 'standard'
&& (node.target.name === 'Array' || node.target.name === 'ReadonlyArray')) {
const element = node.arguments[0]
if (element === undefined) this.fail(node.name, 'tuple rest array has no element type')
return this.typeSchema(element)
return this.typeSchema(element, substitutions)
}
this.fail(id, 'tuple rest element must retain an array type')
}
private objectSchema(members: readonly MemberModel[], subject: string): string {
private objectSchema(
members: readonly MemberModel[],
subject: string,
substitutions: ReadonlyMap<string, string>,
): string {
const properties: string[] = []
const indices: string[] = []
let symbolMembers = 0
for (const member of members) {
if (member.static || member.visibility !== 'public') continue
if (member.computed === 'symbol') {
symbolMembers++
continue
}
if (member.computed === 'dynamic') {
this.fail(subject, `computed member ${member.name} has no fixed JSON property name`)
}
if (member.kind === 'index') {
const parameter = member.signature.parameters[0]
if (member.signature.parameters.length !== 1 || parameter === undefined) {
this.fail(subject, 'index signature must have exactly one key parameter')
}
indices.push(this.readonly(
`z.record(${this.typeSchema(parameter.type, substitutions)}, ${this.typeSchema(member.signature.returns, substitutions)})`,
member.readonly,
))
continue
}
if (member.kind !== 'property') this.fail(subject, `${member.kind} member ${member.name} is not data-schema projectable`)
const property = this.describe(
this.optional(this.readonly(this.typeSchema(member.type), member.readonly), member.optional),
this.optional(this.readonly(this.typeSchema(member.type, substitutions), member.readonly), member.optional),
member,
)
properties.push(`${quote(member.name)}: ${property}`)
properties.push(`${quote(member.jsonName ?? member.name)}: ${property}`)
}
return `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
if (indices.length > 1) this.fail(subject, 'object type has more than one JSON index signature')
// A unique-symbol-only object is a compile-time marker and imposes no JSON shape.
if (properties.length === 0 && indices.length === 0 && symbolMembers > 0) return 'z.unknown()'
const object = `z.object({${properties.length === 0 ? '' : `\n${properties.map(property => ` ${property},`).join('\n')}\n`}})`
const index = indices[0]
if (index === undefined) return object
if (properties.length === 0) return index
return `z.intersection(${object}, ${index})`
}
private exportSchemaName(model: SchemaModel): string {
const name = this.schemaName(model.symbol)
const declaration = this.renderer.declaration(model.symbol)
if (declaration.typeParameters.length > 0) {
this.fail(model.export.name, 'generic schema exports require a concrete declaration')
}
return name
}
private keywordSchema(name: string): string {
@@ -401,6 +794,12 @@ class SchemaEmitter {
return name
}
private boundaryName(key: string): string {
const name = this.boundaryNames.get(key)
if (name === undefined) this.fail(key, 'invocation boundary is outside the selected schema roots')
return name
}
private describe(schema: string, documentation: DocumentationModel): string {
return documentation.description === undefined ? schema : `${schema}.describe(${quote(documentation.description)})`
}
@@ -431,6 +830,77 @@ function documentationLiteral(documentation: DocumentationModel): DocumentationM
}
}
function invocationBoundaryRoots(invocations: readonly InvocationModel[]): BoundarySchemaRoot[] {
const result: BoundarySchemaRoot[] = []
for (const invocation of invocations) {
if (invocation.invocation.kind === 'context') {
result.push({ key: contextBoundaryKey(invocation), type: invocation.invocation.boundary.codecType })
}
invocation.parameters.forEach((parameter, index) => {
result.push({ key: parameterBoundaryKey(invocation, index), type: parameter.boundary.codecType })
})
result.push({ key: resultBoundaryKey(invocation), type: invocation.result.codecType })
}
return result
}
function contextBoundaryKey(invocation: InvocationModel): string {
return `${invocation.id}:context`
}
function parameterBoundaryKey(invocation: InvocationModel, index: number): string {
return `${invocation.id}:parameter:${String(index)}`
}
function resultBoundaryKey(invocation: InvocationModel): string {
return `${invocation.id}:result`
}
function strictCodec(boundary: RemoteBoundaryModel, schema: string): string {
return [
'{',
' mode: \'strict\',',
` typeSymbol: ${quote(boundary.typeSymbol)},`,
` schema: ${schema},`,
'}',
].join('\n')
}
function remoteImports(invocations: readonly InvocationModel[]): RemoteTypeImportModel[] {
const imports = new Map<SymbolId, RemoteTypeImportModel>()
const add = (boundary: RemoteBoundaryModel): void => {
for (const imported of boundary.imports) {
const current = imports.get(imported.symbol)
if (current !== undefined
&& (current.specifier !== imported.specifier || current.name !== imported.name)) {
throw new TypertEmitError(`typert Remote emitter: symbol ${imported.symbol} has inconsistent public imports`)
}
imports.set(imported.symbol, imported)
}
}
for (const invocation of invocations) {
if (invocation.invocation.kind === 'context') add(invocation.invocation.boundary)
for (const parameter of invocation.parameters) add(parameter.boundary)
add(invocation.result)
}
return [...imports.values()].sort((left, right) =>
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name))
}
function allocateRemoteImportNames(imports: readonly RemoteTypeImportModel[]): ReadonlyMap<SymbolId, string> {
const used = new Set(['TypeRTRemoteContribution', 'TYPERT_REMOTE'])
const names = new Map<SymbolId, string>()
for (const imported of imports) {
const base = safeIdentifier(imported.name)
let name = base
let suffix = 2
while (used.has(name)) name = `${base}$remote${String(suffix++)}`
used.add(name)
names.set(imported.symbol, name)
}
return names
}
function packageExportSpecifier(packageName: string, subpath: string): string {
return subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`
}

View File

@@ -94,6 +94,56 @@ export interface SchemaModel extends DocumentationModel {
readonly type: TypeNodeId
}
/** One public business type import retained for a generated Remote declaration. */
export interface RemoteTypeImportModel {
readonly symbol: SymbolId
readonly specifier: string
readonly name: string
}
/** One strict wire boundary and the public symbols needed to name it. */
export interface RemoteBoundaryModel {
/** Authored public type retained for generated consumer declarations. */
readonly type: TypeNodeId
/** Checker-resolved projection used only to emit the runtime codec. */
readonly codecType: TypeNodeId
readonly typeSymbol: string
readonly imports: readonly RemoteTypeImportModel[]
}
/** One ordered business argument projected onto a Remote wire field. */
export interface InvocationParameterModel {
readonly name: string
readonly wire: string
readonly source: 'json' | 'lookup'
readonly lookup?: string
readonly boundary: RemoteBoundaryModel
}
/** One strictly analyzed Host method exported through TypeRT Gateway. */
export interface InvocationModel {
readonly id: string
readonly service: string
readonly namespace: string
readonly method: string
readonly implementation?: string
readonly invocation:
| { readonly kind: 'direct' }
| {
readonly kind: 'context'
readonly context: string
readonly wire: string
readonly boundary: RemoteBoundaryModel
}
readonly scope?: {
readonly context: string
readonly wire: string
}
readonly parameters: readonly InvocationParameterModel[]
readonly result: RemoteBoundaryModel
readonly location: SourceLocation
}
/** Business semantics discovered in one package on one face. */
export interface PackageModel {
readonly name: string
@@ -103,6 +153,7 @@ export interface PackageModel {
readonly events: readonly EventModel[]
readonly objects: readonly ObjectModel[]
readonly schemas: readonly SchemaModel[]
readonly invocations: readonly InvocationModel[]
}
/** One explicit import/re-export edge between independently compiled faces. */
@@ -173,6 +224,10 @@ export interface SignatureModel {
export interface MemberBase extends DocumentationModel {
readonly id: string
readonly name: string
/** JSON property name when a literal computed key differs from source text. */
readonly jsonName?: string
/** Non-literal computed keys; symbol keys are erased from JSON schemas. */
readonly computed?: 'symbol' | 'dynamic'
readonly optional: boolean
readonly readonly: boolean
readonly async: boolean

View File

@@ -81,32 +81,35 @@ export class TypeGraphRenderer {
/**
* Render one type expression from the retained source structure.
* @param id - type node id.
* @param references - optional generated names for declaration references.
* @returns TypeScript type text.
*/
renderType(id: TypeNodeId): string {
renderType(id: TypeNodeId, references?: ReadonlyMap<SymbolId, string>): string {
const node = this.node(id)
switch (node.kind) {
case 'keyword': return node.name
case 'literal': return node.text
case 'parenthesized': return `(${this.renderType(node.type)})`
case 'parenthesized': return `(${this.renderType(node.type, references)})`
case 'reference': {
const name = node.target.kind === 'type-parameter'
? this.parameterNames.get(node.target.parameter) ?? node.name
: node.name
: node.target.kind === 'declaration'
? references?.get(node.target.symbol) ?? node.name
: node.name
return node.arguments.length === 0
? name
: `${name}<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
: `${name}<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
}
case 'union': return node.types.map(type => this.renderType(type)).join(' | ')
case 'intersection': return node.types.map(type => this.renderType(type)).join(' & ')
case 'union': return node.types.map(type => this.renderType(type, references)).join(' | ')
case 'intersection': return node.types.map(type => this.renderType(type, references)).join(' & ')
case 'array': {
const element = this.renderType(node.element)
const element = this.renderType(node.element, references)
const wrapped = needsArrayParentheses(this.node(node.element)) ? `(${element})` : element
return `${wrapped}[]`
}
case 'tuple': {
const elements = node.elements.map((element) => {
const type = this.renderType(element.type)
const type = this.renderType(element.type, references)
if (element.name !== undefined) {
return `${element.rest ? '...' : ''}${element.name}${element.optional ? '?' : ''}: ${type}`
}
@@ -114,34 +117,34 @@ export class TypeGraphRenderer {
})
return `[${elements.join(', ')}]`
}
case 'object': return this.renderObject(node.members)
case 'function': return `${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature)} => ${this.renderType(node.signature.returns)}`
case 'indexed-access': return `${this.renderType(node.object)}[${this.renderType(node.index)}]`
case 'operator': return `${node.operator} ${this.renderType(node.type)}`
case 'object': return this.renderObject(node.members, references)
case 'function': return `${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
case 'constructor': return `${node.abstract ? 'abstract ' : ''}new ${this.renderSignatureHead(node.signature, references)} => ${this.renderType(node.signature.returns, references)}`
case 'indexed-access': return `${this.renderType(node.object, references)}[${this.renderType(node.index, references)}]`
case 'operator': return `${node.operator} ${this.renderType(node.type, references)}`
case 'conditional': {
return `${this.renderType(node.check)} extends ${this.renderType(node.extends)} ? ${this.renderType(node.whenTrue)} : ${this.renderType(node.whenFalse)}`
return `${this.renderType(node.check, references)} extends ${this.renderType(node.extends, references)} ? ${this.renderType(node.whenTrue, references)} : ${this.renderType(node.whenFalse, references)}`
}
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false)}`
case 'infer': return `infer ${this.renderTypeParameter(node.parameter, false, references)}`
case 'mapped': {
const readonly = node.readonly === 'preserve' ? '' : node.readonly === 'remove' ? '-readonly ' : 'readonly '
const optional = node.optional === 'preserve' ? '' : node.optional === 'remove' ? '-?' : '?'
if (node.parameter.constraint === undefined) {
throw new TypeGraphRenderError(`mapped type parameter ${node.parameter.name} has no constraint`)
}
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint)}`
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType)}`
const value = node.value === undefined ? 'unknown' : this.renderType(node.value)
const parameter = `${node.parameter.name} in ${this.renderType(node.parameter.constraint, references)}`
const nameType = node.nameType === undefined ? '' : ` as ${this.renderType(node.nameType, references)}`
const value = node.value === undefined ? 'unknown' : this.renderType(node.value, references)
return `{ ${readonly}[${parameter}${nameType}]${optional}: ${value} }`
}
case 'template-literal': {
const spans = node.spans.map(span => `\${${this.renderType(span.type)}}${escapeTemplate(span.text)}`).join('')
const spans = node.spans.map(span => `\${${this.renderType(span.type, references)}}${escapeTemplate(span.text)}`).join('')
return `\`${escapeTemplate(node.head)}${spans}\``
}
case 'type-query': {
const argumentsText = node.arguments.length === 0
? ''
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
return `typeof ${node.expression}${argumentsText}`
}
case 'import-type': {
@@ -149,14 +152,14 @@ export class TypeGraphRenderer {
const imported = `import(${quote(node.module)}${attributes})${node.qualifier === undefined ? '' : `.${node.qualifier}`}`
const argumentsText = node.arguments.length === 0
? ''
: `<${node.arguments.map(argument => this.renderType(argument)).join(', ')}>`
: `<${node.arguments.map(argument => this.renderType(argument, references)).join(', ')}>`
return `${node.typeof ? 'typeof ' : ''}${imported}${argumentsText}`
}
case 'predicate': {
const assertion = node.asserts ? 'asserts ' : ''
return node.type === undefined
? `${assertion}${node.parameter}`
: `${assertion}${node.parameter} is ${this.renderType(node.type)}`
: `${assertion}${node.parameter} is ${this.renderType(node.type, references)}`
}
case 'this': return 'this'
default: return assertNever(node)
@@ -166,34 +169,36 @@ export class TypeGraphRenderer {
/**
* Render a callable signature without a member name.
* @param signature - modeled signature.
* @param references - optional generated names for declaration references.
* @returns parameter list and return type.
*/
renderSignature(signature: SignatureModel): string {
return `${this.renderSignatureHead(signature)}: ${this.renderType(signature.returns)}`
renderSignature(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `${this.renderSignatureHead(signature, references)}: ${this.renderType(signature.returns, references)}`
}
/**
* Render one class/interface member as a body-free declaration.
* @param member - modeled member.
* @param sourceModifiers - retain source-only modifiers for reflection text.
* @param references - optional generated names for declaration references.
* @returns one-line TypeScript member text.
*/
renderMember(member: MemberModel, sourceModifiers = false): string {
renderMember(member: MemberModel, sourceModifiers = false, references?: ReadonlyMap<SymbolId, string>): string {
if (sourceModifiers) return member.text
const name = renderPropertyName(member.name)
const optional = member.optional ? '?' : ''
const readonly = member.readonly ? 'readonly ' : ''
const abstract = member.abstract ? 'abstract ' : ''
switch (member.kind) {
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type)}`
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature)}`
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature)}`
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature)}`
case 'call': return this.renderSignature(member.signature)
case 'construct': return `new ${this.renderSignature(member.signature)}`
case 'property': return `${abstract}${readonly}${name}${optional}: ${this.renderType(member.type, references)}`
case 'method': return `${abstract}${name}${optional}${this.renderSignature(member.signature, references)}`
case 'getter': return `${abstract}get ${name}()${this.renderReturn(member.signature, references)}`
case 'setter': return `${abstract}set ${name}${this.renderSignatureHead(member.signature, references)}`
case 'call': return this.renderSignature(member.signature, references)
case 'construct': return `new ${this.renderSignature(member.signature, references)}`
case 'index': {
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns)}`
const parameters = member.signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')
return `${readonly}[${parameters}]: ${this.renderType(member.signature.returns, references)}`
}
default: return assertNever(member)
}
@@ -290,38 +295,42 @@ export class TypeGraphRenderer {
return this.graph.declarations.filter(declaration => found.has(declaration.id))
}
private renderSignatureHead(signature: SignatureModel): string {
return `${this.renderTypeParameters(signature.typeParameters)}(${signature.parameters.map(parameter => this.renderParameter(parameter)).join(', ')})`
private renderSignatureHead(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `${this.renderTypeParameters(signature.typeParameters, references)}(${signature.parameters.map(parameter => this.renderParameter(parameter, references)).join(', ')})`
}
private renderReturn(signature: SignatureModel): string {
return `: ${this.renderType(signature.returns)}`
private renderReturn(signature: SignatureModel, references?: ReadonlyMap<SymbolId, string>): string {
return `: ${this.renderType(signature.returns, references)}`
}
private renderParameter(parameter: ParameterModel): string {
private renderParameter(parameter: ParameterModel, references?: ReadonlyMap<SymbolId, string>): string {
const name = parameter.binding === 'identifier' ? renderPropertyName(parameter.name) : parameter.name
const optional = parameter.initializer === undefined && parameter.optional && !parameter.rest ? '?' : ''
const initializer = parameter.initializer === undefined ? '' : ` = ${parameter.initializer}`
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type)}${initializer}`
return `${parameter.rest ? '...' : ''}${name}${optional}: ${this.renderType(parameter.type, references)}${initializer}`
}
private renderTypeParameters(parameters: readonly TypeParameterModel[]): string {
private renderTypeParameters(parameters: readonly TypeParameterModel[], references?: ReadonlyMap<SymbolId, string>): string {
return parameters.length === 0
? ''
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true)).join(', ')}>`
: `<${parameters.map(parameter => this.renderTypeParameter(parameter, true, references)).join(', ')}>`
}
private renderTypeParameter(parameter: TypeParameterModel, includeDefault: boolean): string {
private renderTypeParameter(
parameter: TypeParameterModel,
includeDefault: boolean,
references?: ReadonlyMap<SymbolId, string>,
): string {
const variance = parameter.variance === undefined ? '' : `${parameter.variance === 'in-out' ? 'in out' : parameter.variance} `
const constModifier = parameter.const ? 'const ' : ''
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint)}`
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default)}`
const constraint = parameter.constraint === undefined ? '' : ` extends ${this.renderType(parameter.constraint, references)}`
const fallback = !includeDefault || parameter.default === undefined ? '' : ` = ${this.renderType(parameter.default, references)}`
return `${constModifier}${variance}${parameter.name}${constraint}${fallback}`
}
private renderObject(members: readonly MemberModel[]): string {
private renderObject(members: readonly MemberModel[], references?: ReadonlyMap<SymbolId, string>): string {
if (members.length === 0) return '{}'
return `{ ${members.map(member => `${this.renderMember(member)};`).join(' ')} }`
return `{ ${members.map(member => `${this.renderMember(member, false, references)};`).join(' ')} }`
}
private indexParameters(parameters: readonly TypeParameterModel[]): void {

View File

@@ -2,7 +2,7 @@
* Optional tsdown (rolldown) plugin face of the typert generator. When added
* to a workspace tsdown config, it runs after each opted-in package bundle is
* written and re-emits its model-driven face artifact at the package output
* root. Packages without a Typert export are skipped.
* root. Packages without a Typert or Remote export are skipped.
* @module @deepseek-ai/dsh-typert-generator/tsdown
*/
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { WorkspaceTypertGenerator } from './workspace.ts'
import type { WorkspaceEmitResult } from './workspace.ts'
import type { TypertFace } from './model.ts'
/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */
interface TypertPlugin {
@@ -17,21 +18,37 @@ interface TypertPlugin {
writeBundle: (options: { dir?: string }) => void
}
/** Generation scope selected by a tsdown build phase. */
export interface TypertPluginOptions {
/** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */
readonly mode?: 'package' | 'workspace'
/** Independent TypeScript program faces included in this phase. */
readonly faces?: readonly TypertFace[]
}
/**
* Create the typert generation plugin for the root tsdown config.
* @returns a rolldown-compatible plugin that emits `lib/typert.<face>.js` and `.d.ts` for contributing packages.
* @param pluginOptions - package/workspace emission mode and independent program faces.
* @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts.
*/
export function typertPlugin(): TypertPlugin {
export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin {
const artifactsByRoot = new Map<string, readonly WorkspaceEmitResult[]>()
const emittedWorkspaces = new Set<string>()
return {
name: 'dsh-typert-generator',
writeBundle(options) {
writeBundle(bundleOptions) {
// options.dir is the package's absolute outDir (<package>/lib); its
// nearest package.json owns the bundle even when a custom config writes
// a nested output such as <package>/lib/dev.
if (options.dir === undefined) return
const root = workspaceRoot(options.dir)
const packageDir = packageRoot(options.dir, root)
if (bundleOptions.dir === undefined) return
const root = workspaceRoot(bundleOptions.dir)
if (emittedWorkspaces.has(root)) return
if (pluginOptions.mode === 'workspace') {
emitWorkspace(root, pluginOptions.faces)
emittedWorkspaces.add(root)
return
}
const packageDir = packageRoot(bundleOptions.dir, root)
if (packageDir === undefined) return
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
name?: string
@@ -40,22 +57,54 @@ export function typertPlugin(): TypertPlugin {
if (manifest.name === undefined || !hasTypertExport(manifest.exports)) return
let artifacts = artifactsByRoot.get(root)
if (artifacts === undefined) {
artifacts = new WorkspaceTypertGenerator(root).generate()
const generator = new WorkspaceTypertGenerator(root)
artifacts = pluginOptions.faces === undefined
? generator.generate()
: generator.generate(undefined, pluginOptions.faces)
artifactsByRoot.set(root, artifacts)
}
const output = join(packageDir, 'lib')
mkdirSync(output, { recursive: true })
for (const artifact of artifacts.filter(candidate => candidate.package === manifest.name)) {
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
}
emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name))
},
}
function emitWorkspace(root: string, faces: readonly TypertFace[] | undefined): void {
const generator = new WorkspaceTypertGenerator(root)
const packages = generator.discover(faces)
.filter(candidate => hasTypertExport(readManifest(join(root, candidate.root)).exports))
.map(candidate => candidate.package)
if (packages.length === 0) return
for (const artifact of generator.generate(packages, faces)) {
emitArtifacts(join(root, artifact.packageRoot), [artifact])
}
}
}
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
const output = join(packageDir, 'lib')
mkdirSync(output, { recursive: true })
for (const artifact of artifacts) {
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
if (artifact.remote !== undefined) {
writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js)
writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts)
writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap)
}
}
}
function readManifest(packageDir: string): { name?: string; exports?: unknown } {
return JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
name?: string
exports?: unknown
}
}
function hasTypertExport(exportsField: unknown): boolean {
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
return Object.hasOwn(exportsField, './typert') || Object.hasOwn(exportsField, './client/typert')
return Object.hasOwn(exportsField, './typert')
|| Object.hasOwn(exportsField, './client/typert')
|| Object.hasOwn(exportsField, './remote')
}
function packageRoot(start: string, workspace: string): string | undefined {

View File

@@ -9,6 +9,7 @@ import { TypertAnalysisError, WorkspaceAnalyzer } from './analyzer.ts'
import type { DiscoveredTypertPackage } from './analyzer.ts'
import { FaceModelEmitter } from './emitter.ts'
import type { ModelEmitResult } from './emitter.ts'
import type { TypertFace } from './model.ts'
/** One emitted artifact paired with its source package root. */
export interface WorkspaceEmitResult extends ModelEmitResult {
@@ -26,20 +27,29 @@ export class WorkspaceTypertGenerator {
/**
* Find public package faces that contribute Cordis services/events or
* explicitly tagged Typert roots.
* @param faces - optional independent program faces to inspect.
* @returns discovered packages in stable package-name order.
*/
discover(): DiscoveredTypertPackage[] {
return new WorkspaceAnalyzer({ root: this.root }).discoverPackages()
discover(faces?: readonly TypertFace[]): DiscoveredTypertPackage[] {
return new WorkspaceAnalyzer({
root: this.root,
...(faces === undefined ? {} : { faces }),
}).discoverPackages()
}
/**
* Generate all discovered contributors, or an explicit package subset.
* @param packages - optional exact package names for a focused pass.
* @param faces - optional independent program faces to analyze.
* @returns one artifact per package face.
*/
generate(packages?: readonly string[]): WorkspaceEmitResult[] {
const selected = packages ?? this.discover().map(candidate => candidate.package)
const workspace = new WorkspaceAnalyzer({ root: this.root, packages: selected }).analyze()
generate(packages?: readonly string[], faces?: readonly TypertFace[]): WorkspaceEmitResult[] {
const selected = packages ?? this.discover(faces).map(candidate => candidate.package)
const workspace = new WorkspaceAnalyzer({
root: this.root,
packages: selected,
...(faces === undefined ? {} : { faces }),
}).analyze()
const artifacts: WorkspaceEmitResult[] = []
for (const face of workspace.faces) {
const emitter = new FaceModelEmitter(face)
@@ -80,6 +90,28 @@ export class WorkspaceTypertGenerator {
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
}
}
if (artifact.remote === undefined) return
const remoteExpected = {
types: './lib/typert.remote-client.d.ts',
default: './lib/typert.remote-client.js',
}
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
? (manifest.exports as Record<string, unknown>)['./remote']
: undefined
if (!sameExport(remoteActual, remoteExpected)) {
throw new TypertAnalysisError(
`typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`,
)
}
for (const file of [
'lib/typert.remote-client.js',
'lib/typert.remote-client.d.ts',
'lib/typert.remote-client.d.ts.map',
]) {
if (!files.includes(file)) {
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
}
}
}
}

View File

@@ -17,6 +17,8 @@ export const TYPERT = {
schemas: [
{ name: 'Payload', schema: Payload },
],
invocations: [
],
model: {
"services": [
{
@@ -3815,6 +3817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
"abstract": false,
"async": false,
"id": "type:packages/host/src/models.ts:123:11#1#['computed']@3756",
"jsonName": "computed",
"kind": "property",
"location": {
"column": 5,
@@ -5634,6 +5637,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
"symbol": "@fixture/host:packages/host/src/models.ts#Variance",
},
],
"invocations": [],
"name": "@fixture/host",
"objects": [
{
@@ -6449,6 +6453,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro
"symbol": "<external>:../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType",
},
],
"invocations": [],
"name": "@fixture/client",
"objects": [],
"root": "packages/client",

View File

@@ -0,0 +1,5 @@
{
"name": "@fixture/remote-workspace",
"private": true,
"type": "module"
}

View File

@@ -0,0 +1,9 @@
{
"name": "@fixture/domain",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts"
}
}

View File

@@ -0,0 +1,19 @@
import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
import type { AgentId } from './types.ts'
/** Host-only live Agent object. */
export class Agent {
constructor(readonly id: AgentId) {}
}
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTLookupMap {
agent: TypeRTLookup<Agent, AgentId>
}
interface TypeRTContextMap {
agent: TypeRTContext<AgentId>
}
}
export type { AgentId } from './types.ts'

View File

@@ -0,0 +1,2 @@
/** Stable Agent identity crossing the Remote boundary. */
export type AgentId = string

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@fixture/remote",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts",
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
}
},
"files": [
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts",
"lib/typert.remote-client.d.ts.map"
]
}

View File

@@ -0,0 +1,30 @@
import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta'
import type { Agent } from '@fixture/domain'
import type {
CreateGoalRequest,
CreateGoalResult,
RenameGoalRequest,
RenameGoalResult,
} from './types.ts'
/** Remote-only business Service with no Cordis declaration merge. */
export class GoalService {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
@Remote
async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {
return { ref: `${agent.id}:${request.title}` }
}
@RemoteContext('agent')
rename(request: RenameGoalRequest): RenameGoalResult {
return { renamed: request.title.length > 0 }
}
}
export type {
CreateGoalRequest,
CreateGoalResult,
RenameGoalRequest,
RenameGoalResult,
} from './types.ts'

View File

@@ -0,0 +1,20 @@
/** Input accepted by Goal creation. */
export interface CreateGoalRequest {
readonly title: string
}
/** Wire-safe Goal creation result. */
export interface CreateGoalResult {
readonly ref: string
}
/** Input accepted by scoped Goal renaming. */
export interface RenameGoalRequest {
readonly ref: string
readonly title: string
}
/** Wire-safe Goal rename result. */
export interface RenameGoalResult {
readonly renamed: boolean
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"],
"references": [
{ "path": "../domain" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"composite": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"ignoreDeprecations": "6.0",
"paths": {
"@deepseek-ai/dsh-type-meta": ["./type-meta.d.ts"],
"@fixture/domain": ["./packages/domain/src/index.ts"],
"@fixture/domain/*": ["./packages/domain/src/*"],
"@fixture/remote": ["./packages/remote/src/index.ts"],
"@fixture/remote/*": ["./packages/remote/src/*"]
},
"skipLibCheck": true
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/domain" },
{ "path": "./packages/remote" }
]
}

View File

@@ -0,0 +1,45 @@
declare module '@deepseek-ai/dsh-type-meta' {
export interface TypeRTLookup<Host, Wire> {
readonly host: Host
readonly wire: Wire
}
export interface TypeRTContext<Wire> {
readonly wire: Wire
}
export interface TypeRTLookupMap {}
export interface TypeRTContextMap {}
export interface TypeRTRemoteMap {}
export interface TypeRTRemoteContextMap {}
export type TypeRTRemoteNamespace<Namespace extends string> = {
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
? Method
: never]: TypeRTRemoteMap[Endpoint]
}
export interface TypeRTRemoteNamespaceMap {}
export interface TypeRTRemoteContribution {
readonly package: string
readonly descriptors: readonly unknown[]
}
export function bindTypeRTGateway<Service extends object>(
service: Service,
serviceKey: string,
options?: { readonly namespace?: string },
): { readonly service: Service; readonly serviceKey: string; readonly namespace: string }
export function Remote<This extends object, Args extends unknown[], Result>(
method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void
export function RemoteContext(key: Extract<keyof TypeRTContextMap, string>):
<This extends object, Args extends unknown[], Result>(
method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
) => void
}

View File

@@ -0,0 +1,486 @@
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import ts from 'typescript'
import { afterEach, describe, expect, it } from 'vitest'
import { WorkspaceAnalyzer } from '../src/analyzer.ts'
import type { InvocationModel } from '../src/model.ts'
import { WorkspaceTypertGenerator } from '../src/workspace.ts'
const fixtureRoot = resolve(import.meta.dirname, 'fixtures/remote-model')
const temporaryRoots: string[] = []
interface RuntimeSchema {
safeParse(value: unknown): { readonly success: boolean }
}
interface RuntimeDescriptor {
readonly id: string
readonly parameters: readonly {
readonly wire: string
readonly codec: { readonly schema: RuntimeSchema }
}[]
readonly result: { readonly schema: RuntimeSchema }
}
interface RuntimeRemoteModule {
readonly TYPERT_REMOTE: {
readonly package: string
readonly descriptors: readonly RuntimeDescriptor[]
}
}
interface RemoteDeclarationMap {
readonly file: string
readonly names: readonly string[]
readonly sources: readonly string[]
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('Remote model generation', { timeout: 60_000 }, () => {
it('discovers a Remote-only package and emits strict direct and Context descriptors', async () => {
const generator = new WorkspaceTypertGenerator(fixtureRoot)
expect(generator.discover()).toEqual([{
package: '@fixture/remote',
root: 'packages/remote',
faces: ['host'],
}])
const [artifact] = generator.generate()
expect(artifact).toBeDefined()
expect(artifact).toMatchObject({
package: '@fixture/remote',
face: 'host',
packageRoot: 'packages/remote',
})
const model = remotePackage(fixtureRoot)
expect(model.services).toEqual([])
expect(model.invocations).toHaveLength(2)
expect(model.invocations[0]).toMatchObject({
id: '@fixture/remote#goals/create',
service: 'goals',
namespace: 'goals',
method: 'create',
invocation: { kind: 'direct' },
scope: { context: 'agent', wire: 'agentId' },
parameters: [
{
name: 'agent',
wire: 'agentId',
source: 'lookup',
lookup: 'agent',
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
},
{
name: 'request',
wire: 'request',
source: 'json',
boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' },
},
],
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
})
expect(model.invocations[1]).toMatchObject({
id: '@fixture/remote#goals/rename',
service: 'goals',
namespace: 'goals',
method: 'rename',
invocation: {
kind: 'context',
context: 'agent',
wire: 'agentId',
boundary: { typeSymbol: '@fixture/domain/types#AgentId' },
},
parameters: [{
name: 'request',
wire: 'request',
source: 'json',
boundary: { typeSymbol: '@fixture/remote/types#RenameGoalRequest' },
}],
result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
})
expect(artifact?.js).toContain('invocations: [')
expect(artifact?.remote?.dts).toContain(
"'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise<CreateGoalResult>",
)
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
expect(artifact?.remote?.dts).toContain(
"'agent:goals/create': (request: CreateGoalRequest) => Promise<CreateGoalResult>",
)
expect(artifact?.remote?.dts).toContain(
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
)
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
const create = generated.TYPERT_REMOTE.descriptors[0]
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
expect(declarationMap).toMatchObject({
file: 'typert.remote-client.d.ts',
sources: ['../src/index.ts'],
})
expect(declarationMap.names).toContain('create')
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap)
})
it('evaluates declaration-merged mapped and conditional boundaries for codecs without widening consumer types', async () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/types.ts', source => `${source}
/** Recursive JSON fixture used by the concrete codec projection. */
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
/** Merge-extensible operation table represented by concrete fixture entries. */
export interface GenericRemoteMap {
ship: {
readonly request: { readonly count: number; readonly meta: Json }
readonly result: { readonly accepted: boolean }
}
cancel: {
readonly request: { readonly reason: string }
readonly result: { readonly cancelled: boolean }
}
}
type GenericRemoteKey = Extract<keyof GenericRemoteMap, string>
type RequestOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly request: infer Request }
? Request
: never
type ResultOf<K extends GenericRemoteKey> = GenericRemoteMap[K] extends { readonly result: infer Result }
? Result
: never
/** Strict request union retained in the generated Client declaration. */
export type GenericRequest = {
[K in GenericRemoteKey]: { readonly kind: K; readonly payload: RequestOf<K> }
}[GenericRemoteKey]
/** Strict result union retained in the generated Client declaration. */
export type GenericResult = {
[K in GenericRemoteKey]: { readonly kind: K; readonly value: ResultOf<K> }
}[GenericRemoteKey]
`)
editFile(root, 'packages/remote/src/index.ts', source => source
.replace(
' RenameGoalResult,\n',
' RenameGoalResult,\n GenericRequest,\n GenericResult,\n',
)
.replace(
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
` rename(request: RenameGoalRequest): RenameGoalResult {
return { renamed: request.title.length > 0 }
}
@Remote
dispatch(request: GenericRequest): GenericResult {
if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
}
}`,
))
const [artifact] = new WorkspaceTypertGenerator(root).generate()
expect(artifact?.remote?.dts).toContain(
"'goals/dispatch': (request: GenericRequest) => Promise<GenericResult>",
)
const remoteJs = artifact?.remote?.js
if (remoteJs === undefined) throw new Error('generic Remote fixture emitted no Host-for-Client JavaScript')
const executable = remoteJs.replace("from 'zod'", `from ${JSON.stringify(import.meta.resolve('zod'))}`)
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
const dispatch = generated.TYPERT_REMOTE.descriptors.find(descriptor => descriptor.id.endsWith('/dispatch'))
const schema = dispatch?.parameters[0]?.codec.schema
expect(schema?.safeParse({ kind: 'ship', payload: { count: 2, meta: { nested: [true, null] } } }).success).toBe(true)
expect(schema?.safeParse({ kind: 'ship', payload: { count: '2', meta: {} } }).success).toBe(false)
expect(schema?.safeParse({ kind: 'cancel', payload: { reason: 'obsolete' } }).success).toBe(true)
expect(schema?.safeParse({ kind: 'unknown', payload: {} }).success).toBe(false)
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { accepted: true } }).success).toBe(true)
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
})
it.each([
{
name: 'missing binding',
edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''),
message: 'Remote methods require readonly typertGateway',
},
{
name: 'private method',
edit: (source: string) => source.replace(' async create(', ' private async create('),
message: 'Remote decorators require a public instance method',
},
{
name: 'static method',
edit: (source: string) => source.replace(' async create(', ' static async create('),
message: 'Remote decorators require a public instance method',
},
{
name: 'abstract method',
edit: (source: string) => source
.replace('export class GoalService', 'export abstract class GoalService')
.replace(
' async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {\n return { ref: `${agent.id}:${request.title}` }\n }',
' abstract create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult>',
),
message: 'Remote methods must have a concrete implementation',
},
{
name: 'generic method',
edit: (source: string) => source.replace(' async create(', ' async create<Value>('),
message: 'generic Remote methods are not supported',
},
{
name: 'destructured parameter',
edit: (source: string) => source.replace('request: CreateGoalRequest', '{ title }: CreateGoalRequest'),
message: 'Remote parameters must use identifier bindings',
},
{
name: 'rest parameter',
edit: (source: string) => source.replace('request: CreateGoalRequest', '...request: [CreateGoalRequest]'),
message: 'Remote parameters cannot be rest parameters',
},
{
name: 'default parameter',
edit: (source: string) => source.replace(
'request: CreateGoalRequest',
"request: CreateGoalRequest = { title: '' }",
),
message: 'Remote parameters cannot have default values',
},
{
name: 'optional parameter',
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
message: 'Remote parameters cannot be optional',
},
])('rejects $name', ({ edit, message }) => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', edit)
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
})
it('rejects a workspace class parameter without a lookup declaration', () => {
const root = copyFixture()
editFile(root, 'packages/domain/src/index.ts', source => source.replace(
' interface TypeRTLookupMap {\n agent: TypeRTLookup<Agent, AgentId>\n }\n\n',
'',
))
expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/)
})
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')"))
expect(() => analyzeRemote(root, false)).toThrow(/Remote Context missing has no TypeRTContextMap entry/)
})
it('rejects a direct scoped projection whose Context and lookup wire symbols differ', () => {
const root = copyFixture()
editFile(root, 'packages/domain/src/types.ts', source => `${source}\n/** Deliberately distinct Context identity for the failure fixture. */\nexport type OtherAgentId = string\n`)
editFile(root, 'packages/domain/src/index.ts', source => source
.replace("import type { AgentId } from './types.ts'", "import type { AgentId, OtherAgentId } from './types.ts'")
.replace('agent: TypeRTContext<AgentId>', 'agent: TypeRTContext<OtherAgentId>'))
expect(() => analyzeRemote(root, false)).toThrow(/Remote scope agent wire type .* does not match lookup wire type/)
})
it('rejects duplicate endpoints across Remote services', () => {
const root = copyFixture()
editFile(root, 'packages/remote/src/index.ts', source => `${source}
export class DuplicateGoalService {
readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' })
@Remote
create(request: CreateGoalRequest): CreateGoalResult {
return { ref: request.title }
}
}
`)
expect(() => analyzeRemote(root, false)).toThrow(/Remote endpoint goals\/create conflicts/)
})
})
function analyzeRemote(root: string, checkDiagnostics = true): ReturnType<WorkspaceAnalyzer['analyze']> {
return new WorkspaceAnalyzer({ root, checkDiagnostics }).analyze()
}
function remotePackage(root: string): {
readonly services: readonly unknown[]
readonly invocations: readonly InvocationModel[]
} {
const host = analyzeRemote(root).faces.find(face => face.face === 'host')
const packageModel = host?.packages.find(candidate => candidate.name === '@fixture/remote')
if (packageModel === undefined) throw new Error('Remote fixture package was not modeled on the host face')
return packageModel
}
function copyFixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
cpSync(fixtureRoot, root, { recursive: true })
temporaryRoots.push(root)
return root
}
function editFile(root: string, relativePath: string, edit: (source: string) => string): void {
const path = join(root, relativePath)
const source = readFileSync(path, 'utf8')
const result = edit(source)
if (result === source) throw new Error(`fixture edit made no change to ${relativePath}`)
writeFileSync(path, result)
}
function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void {
if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration')
if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map')
const consumerRoot = copyFixture()
const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts')
const declarationMapPath = `${declarationPath}.map`
const consumerPath = join(consumerRoot, 'consumer.ts')
mkdirSync(join(consumerRoot, 'packages/remote/lib'), { recursive: true })
writeFileSync(declarationPath, dts, { flush: true })
writeFileSync(declarationMapPath, dtsMap, { flush: true })
assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot)
const consumerSource = `
import remote from '@fixture/remote/remote'
import type {
TypeRTRemoteContribution,
TypeRTRemoteContextMap,
TypeRTRemoteMap,
TypeRTRemoteNamespaceMap,
} from '@deepseek-ai/dsh-type-meta'
import type { CreateGoalResult, RenameGoalResult } from '@fixture/remote/types'
const contribution: TypeRTRemoteContribution = remote
declare const create: TypeRTRemoteMap['goals/create']
declare const createScoped: TypeRTRemoteContextMap['agent:goals/create']
declare const rename: TypeRTRemoteContextMap['agent:goals/rename']
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
declare const ctx: { api: TypeRTRemoteNamespaceMap }
const navigated: Promise<CreateGoalResult> = ctx.api.goals.create('agent-1', { title: 'navigate' })
void contribution
void created
void createdScoped
void renamed
void navigated
`
writeFileSync(consumerPath, consumerSource)
const configPath = join(consumerRoot, 'tsconfig.consumer.json')
writeFileSync(configPath, JSON.stringify({
extends: './tsconfig.base.json',
compilerOptions: {
composite: false,
skipLibCheck: false,
paths: {
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
'@fixture/domain/types': ['./packages/domain/src/types.ts'],
'@fixture/remote/types': ['./packages/remote/src/types.ts'],
'@fixture/remote/remote': ['./packages/remote/lib/typert.remote-client.d.ts'],
},
},
files: ['./consumer.ts'],
}, null, 2))
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
const program = ts.createProgram(parsed.fileNames, parsed.options)
const diagnostics = ts.getPreEmitDiagnostics(program)
expect(diagnostics, formatDiagnostics(diagnostics)).toEqual([])
const languageService = ts.createLanguageService({
getCompilationSettings: () => parsed.options,
getCurrentDirectory: () => consumerRoot,
getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
getScriptFileNames: () => parsed.fileNames,
getScriptSnapshot: (fileName) => {
const source = ts.sys.readFile(fileName)
return source === undefined ? undefined : ts.ScriptSnapshot.fromString(source)
},
getScriptVersion: () => '0',
directoryExists: path => ts.sys.directoryExists(path),
fileExists: path => ts.sys.fileExists(path),
getDirectories: path => ts.sys.getDirectories(path),
readDirectory: (path, extensions, exclude, include, depth) =>
ts.sys.readDirectory(path, extensions, exclude, include, depth),
readFile: path => ts.sys.readFile(path),
realpath: path => ts.sys.realpath?.(path) ?? path,
})
const navigation = 'ctx.api.goals.create'
const position = consumerSource.indexOf(navigation) + navigation.lastIndexOf('create') + 1
const definitions = languageService.getDefinitionAtPosition(consumerPath, position)
const generatedDefinition = definitions?.find(candidate => candidate.fileName === declarationPath)
if (generatedDefinition === undefined) {
throw new Error(`generated Remote definition not found: ${JSON.stringify(definitions, null, 2)}`)
}
const sourceMapper = (languageService as unknown as {
getSourceMapper(): {
tryGetSourcePosition(location: { readonly fileName: string; readonly pos: number }):
{ readonly fileName: string; readonly pos: number } | undefined
}
}).getSourceMapper()
const definition = sourceMapper.tryGetSourcePosition({
fileName: generatedDefinition.fileName,
pos: generatedDefinition.textSpan.start,
})
languageService.dispose()
if (definition === undefined || !definition.fileName.endsWith('/packages/remote/src/index.ts')) {
throw new Error(`generated Remote definition did not map to its Host source: ${JSON.stringify(definition)}`)
}
const hostSource = readFileSync(join(consumerRoot, 'packages/remote/src/index.ts'), 'utf8')
expect(hostSource.slice(definition.pos, definition.pos + generatedDefinition.textSpan.length)).toBe('create')
}
function assertRemoteConsumerWithoutImportHasNoNamespace(consumerRoot: string): void {
const consumerPath = join(consumerRoot, 'consumer-without-remote.ts')
writeFileSync(consumerPath, `
import type { TypeRTRemoteNamespaceMap } from '@deepseek-ai/dsh-type-meta'
declare const ctx: { api: TypeRTRemoteNamespaceMap }
ctx.api.goals.create('agent-1', { title: 'must not compile' })
`)
const configPath = join(consumerRoot, 'tsconfig.consumer-without-remote.json')
writeFileSync(configPath, JSON.stringify({
extends: './tsconfig.base.json',
compilerOptions: {
composite: false,
skipLibCheck: false,
paths: {
'@deepseek-ai/dsh-type-meta': ['./type-meta.d.ts'],
},
},
files: ['./consumer-without-remote.ts'],
}, null, 2))
const config = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options))
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.code).toBe(2339)
expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist")
}
function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string {
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: file => file,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => '\n',
})
}

View File

@@ -7,6 +7,7 @@ import type {
FaceModel,
KeywordTypeName,
MemberModel,
SignatureMemberModel,
SignatureModel,
TypeDeclarationModel,
TypeNodeModel,
@@ -356,6 +357,149 @@ describe('SchemaEmitter supported projection matrix', () => {
expect(inheritedSchema.safeParse({ current: 1 }).success).toBe(false)
})
it('instantiates generic aliases, nested references, defaults, and recursive declarations', async () => {
const box = declaration('Box', 'interface', {
typeParameters: [{ id: 'box:value', name: 'Value', const: false }],
members: [property('value', 'box:value-reference')],
})
const wrapper = declaration('Wrapper', 'alias', {
typeParameters: [
{ id: 'wrapper:value', name: 'Value', const: false },
{ id: 'wrapper:items', name: 'Items', const: false, default: 'wrapper:default-items' },
],
type: 'wrapper:box-reference',
})
const recursive = declaration('Recursive', 'interface', {
typeParameters: [{ id: 'recursive:value', name: 'Value', const: false }],
members: [
property('value', 'recursive:value-reference'),
property('next', 'recursive:self-reference', { optional: true }),
],
})
const schema = await loadSchema(emit([
{
id: 'root',
kind: 'object',
members: [
property('wrapped', 'root:wrapper-reference'),
property('recursive', 'root:recursive-reference'),
],
},
{
id: 'root:wrapper-reference',
kind: 'reference',
name: 'Wrapper',
target: { kind: 'declaration', symbol: 'Wrapper' },
arguments: ['string'],
},
{
id: 'root:recursive-reference',
kind: 'reference',
name: 'Recursive',
target: { kind: 'declaration', symbol: 'Recursive' },
arguments: ['number'],
},
{
id: 'wrapper:box-reference',
kind: 'reference',
name: 'Box',
target: { kind: 'declaration', symbol: 'Box' },
arguments: ['wrapper:items-reference'],
},
{
id: 'wrapper:default-items',
kind: 'reference',
name: 'ReadonlyArray',
target: { kind: 'standard', name: 'ReadonlyArray' },
arguments: ['wrapper:value-reference'],
},
{
id: 'wrapper:value-reference',
kind: 'reference',
name: 'Value',
target: { kind: 'type-parameter', parameter: 'wrapper:value' },
arguments: [],
},
{
id: 'wrapper:items-reference',
kind: 'reference',
name: 'Items',
target: { kind: 'type-parameter', parameter: 'wrapper:items' },
arguments: [],
},
{
id: 'box:value-reference',
kind: 'reference',
name: 'Value',
target: { kind: 'type-parameter', parameter: 'box:value' },
arguments: [],
},
{
id: 'recursive:value-reference',
kind: 'reference',
name: 'Value',
target: { kind: 'type-parameter', parameter: 'recursive:value' },
arguments: [],
},
{
id: 'recursive:self-reference',
kind: 'reference',
name: 'Recursive',
target: { kind: 'declaration', symbol: 'Recursive' },
arguments: ['recursive:value-reference'],
},
keyword('string', 'string'),
keyword('number', 'number'),
], undefined, [box, wrapper, recursive]))
expect(schema.safeParse({
wrapped: { value: ['one', 'two'] },
recursive: { value: 1, next: { value: 2 } },
}).success).toBe(true)
expect(schema.safeParse({
wrapped: { value: [1] },
recursive: { value: 1 },
}).success).toBe(false)
expect(schema.safeParse({
wrapped: { value: ['one'] },
recursive: { value: 'one' },
}).success).toBe(false)
})
it('erases unique-symbol nominal members without naming a branding utility', async () => {
const nominal = declaration('Nominal', 'alias', {
typeParameters: [{ id: 'nominal:brand', name: 'Brand', const: false }],
type: 'nominal:intersection',
})
const symbolMember = {
...property('[TOKEN]', 'nominal:brand-reference', { readonly: true }),
computed: 'symbol',
} as const
const schema = await loadSchema(emit([
{
id: 'root',
kind: 'reference',
name: 'Nominal',
target: { kind: 'declaration', symbol: 'Nominal' },
arguments: ['brand'],
},
{ id: 'brand', kind: 'literal', value: 'Fixture', text: "'Fixture'" },
{ id: 'nominal:intersection', kind: 'intersection', types: ['string', 'nominal:marker'] },
keyword('string', 'string'),
{ id: 'nominal:marker', kind: 'object', members: [symbolMember] },
{
id: 'nominal:brand-reference',
kind: 'reference',
name: 'Brand',
target: { kind: 'type-parameter', parameter: 'nominal:brand' },
arguments: [],
},
], undefined, [nominal]))
expect(schema.safeParse('fixture-id').success).toBe(true)
expect(schema.safeParse(1).success).toBe(false)
})
it('classifies every TypeNode kind and executes every supported kind', () => {
const expected = Object.entries(ZOD_NODE_SUPPORT)
.filter(([, support]) => support === 'supported')
@@ -373,7 +517,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
})
it.each([
['type-parameter', { kind: 'type-parameter', parameter: 'parameter' }],
['cross-face', { kind: 'cross-face', face: 'client', package: '@fixture/client', subpath: '.', name: 'Value' }],
['external', { kind: 'external', module: 'external', subpath: '.', name: 'Value' }],
] as const)('rejects %s references explicitly', (kind, target) => {
@@ -386,7 +529,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}])).toThrow(`typert Zod emitter: Value: ${kind} reference has no Zod projection`)
})
it('rejects unsupported standard references, generic declarations, and enums', () => {
it('rejects unbound type parameters, incomplete generic applications, and generic schema exports', () => {
expect(() => emit([{
id: 'root',
kind: 'reference',
name: 'Value',
target: { kind: 'type-parameter', parameter: 'parameter' },
arguments: [],
}])).toThrow('type parameter has no schema substitution')
const generic = declaration('Generic', 'interface', {
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
})
expect(() => emit([{
id: 'root',
kind: 'reference',
name: 'Generic',
target: { kind: 'declaration', symbol: 'Generic' },
arguments: [],
}], undefined, [generic])).toThrow('missing type argument Value')
const genericRoot = declaration('Root', 'interface', {
typeParameters: [{ id: 'root:parameter', name: 'Value', const: false }],
})
expect(() => emit([], genericRoot)).toThrow('generic schema exports require a concrete declaration')
})
it('rejects unsupported standard references and enums', () => {
const intrinsic = { id: 'root', kind: 'keyword', name: 'intrinsic' } as unknown as TypeNodeModel
expect(() => emit([intrinsic]))
.toThrow('keyword intrinsic has no Zod projection')
@@ -399,17 +568,6 @@ describe('SchemaEmitter unsupported projection matrix', () => {
arguments: [],
}])).toThrow('standard type Promise has no Zod projection')
const generic = declaration('Generic', 'interface', {
typeParameters: [{ id: 'parameter', name: 'Value', const: false }],
})
expect(() => emit([{
id: 'root',
kind: 'reference',
name: 'Generic',
target: { kind: 'declaration', symbol: 'Generic' },
arguments: [],
}], undefined, [generic])).toThrow('generic declarations require a schema-factory projection')
const enumeration = declaration('Enumeration', 'enum', {
enumMembers: [{ ...documentation, name: 'Value', initializer: "'value'", location }],
})
@@ -481,6 +639,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}],
objects: [],
schemas: [],
invocations: [],
}],
}
expect(() => new FaceModelEmitter(eventFace).emit('@fixture/schema'))
@@ -513,6 +672,7 @@ describe('SchemaEmitter unsupported projection matrix', () => {
}],
objects: [],
schemas: [],
invocations: [],
}],
}
@@ -555,7 +715,33 @@ describe('SchemaEmitter unsupported projection matrix', () => {
expect(artifact.dts).toContain("from '@fixture/schema/secondary'")
})
it.each(['method', 'getter', 'setter', 'call', 'construct', 'index'] as const)(
it('emits JSON index signatures as record schemas', async () => {
const root = declaration('Root', 'interface', {
members: [indexMember('key', 'value')],
})
const schema = await loadSchema(emit([
keyword('key', 'string'),
keyword('value', 'number'),
], root))
expect(schema.safeParse({ one: 1, two: 2 }).success).toBe(true)
expect(schema.safeParse({ one: '1' }).success).toBe(false)
})
it('rejects more than one JSON index signature', () => {
const root = declaration('Root', 'interface', {
members: [indexMember('key', 'value'), indexMember('other-key', 'other-value')],
})
expect(() => emit([
keyword('key', 'string'),
keyword('value', 'number'),
keyword('other-key', 'string'),
keyword('other-value', 'boolean'),
], root)).toThrow('object type has more than one JSON index signature')
})
it.each(['method', 'getter', 'setter', 'call', 'construct'] as const)(
'rejects %s members on data-schema objects',
(kind) => {
expect(() => emit([
@@ -608,6 +794,10 @@ function property(
}
}
function signatureMember(kind: 'index'): SignatureMemberModel
function signatureMember(
kind: Exclude<MemberModel['kind'], 'property' | 'index'>,
): MemberModel
function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): MemberModel {
return {
...documentation,
@@ -626,6 +816,24 @@ function signatureMember(kind: Exclude<MemberModel['kind'], 'property'>): Member
}
}
function indexMember(key: string, value: string): SignatureMemberModel {
return {
...signatureMember('index'),
signature: {
typeParameters: [],
parameters: [{
name: 'key',
binding: 'identifier',
type: key,
optional: false,
rest: false,
receiver: false,
}],
returns: value,
},
}
}
function declaration(
name: string,
kind: TypeDeclarationModel['kind'],
@@ -684,6 +892,7 @@ function emit(
symbol: 'Root',
type: 'schema-reference',
}],
invocations: [],
}],
}
return new FaceModelEmitter(face).emit('@fixture/schema').js
@@ -710,6 +919,7 @@ function schemaFace(
symbol,
type: 'root',
}],
invocations: [],
}],
}
}

View File

@@ -62,7 +62,7 @@ describe('model-driven dsh-tools generation', () => {
TYPE_API.find(type => type.name === 'ToolDefinition'),
)
dispose()
await dispose()
expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined()
})
})

View File

@@ -12,6 +12,11 @@ const generated = vi.hoisted(() => vi.fn(() => [
exports: [],
js: 'export const host = true\n',
dts: 'export declare const host: true\n',
remote: {
js: 'export const remote = true\n',
dts: 'export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
dtsMap: '{"version":3}\n',
},
},
{
package: '@deepseek-ai/dsh-tools',
@@ -21,10 +26,30 @@ const generated = vi.hoisted(() => vi.fn(() => [
js: 'export const client = true\n',
dts: 'export declare const client: true\n',
},
{
package: '@fixture/remote-only',
packageRoot: 'packages/remote-only',
face: 'host' as const,
exports: [],
js: 'export const local = true\n',
dts: 'export declare const local: true\n',
remote: {
js: 'export const remoteOnly = true\n',
dts: 'export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n',
dtsMap: '{"version":3}\n',
},
},
]))
const discovered = vi.hoisted(() => vi.fn(() => [
{ package: '@deepseek-ai/dsh-tools', root: 'packages/core/tools', faces: ['host'] },
{ package: '@fixture/ignored', root: 'packages/ignored', faces: ['host'] },
{ package: '@fixture/remote-only', root: 'packages/remote-only', faces: ['host'] },
]))
vi.mock('../src/workspace.ts', () => ({
WorkspaceTypertGenerator: class {
discover = discovered
generate = generated
},
}))
@@ -33,6 +58,7 @@ const { typertPlugin } = await import('../src/tsdown-plugin.ts')
const roots: string[] = []
afterEach(() => {
discovered.mockClear()
generated.mockClear()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
@@ -80,9 +106,64 @@ describe('typertPlugin', () => {
expect(readFileSync(join(packageLib, 'typert.host.d.ts'), 'utf8')).toBe('export declare const host: true\n')
expect(readFileSync(join(packageLib, 'typert.client.js'), 'utf8')).toBe('export const client = true\n')
expect(existsSync(join(packageLib, 'typert.client.d.ts'))).toBe(true)
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8')).toBe('export const remote = true\n')
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
.toBe('export declare const remote: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
.toBe('{"version":3}\n')
expect(readFileSync(join(root, 'packages/client-tools/lib/typert.client.js'), 'utf8'))
.toBe('export const client = true\n')
})
it('generates a package opted in only through its Remote export', async () => {
const root = await workspace()
const output = await packageOutput(root, 'remote-only', {
name: '@fixture/remote-only',
exports: { './remote': './lib/typert.remote-client.js' },
})
typertPlugin().writeBundle({ dir: output })
const packageLib = join(root, 'packages', 'remote-only', 'lib')
expect(generated).toHaveBeenCalledOnce()
expect(readFileSync(join(packageLib, 'typert.remote-client.js'), 'utf8'))
.toBe('export const remoteOnly = true\n')
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts'), 'utf8'))
.toBe('export declare const remoteOnly: true\n//# sourceMappingURL=typert.remote-client.d.ts.map\n')
expect(readFileSync(join(packageLib, 'typert.remote-client.d.ts.map'), 'utf8'))
.toBe('{"version":3}\n')
})
it('emits every explicit workspace contributor once from a host-only prepass', async () => {
const root = await workspace()
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })
await packageOutput(root, 'core/tools', {
name: '@deepseek-ai/dsh-tools',
exports: { './typert': './lib/typert.host.js' },
})
await packageOutput(root, 'ignored', { name: '@fixture/ignored' })
await packageOutput(root, 'remote-only', {
name: '@fixture/remote-only',
exports: { './remote': './lib/typert.remote-client.js' },
})
const plugin = typertPlugin({ mode: 'workspace', faces: ['host'] })
plugin.writeBundle({ dir: trigger })
plugin.writeBundle({ dir: join(root, 'packages/core/tools/lib/dev') })
expect(discovered).toHaveBeenCalledOnce()
expect(discovered).toHaveBeenCalledWith(['host'])
expect(generated).toHaveBeenCalledOnce()
expect(generated).toHaveBeenCalledWith(
['@deepseek-ai/dsh-tools', '@fixture/remote-only'],
['host'],
)
expect(readFileSync(join(root, 'packages/core/tools/lib/typert.host.js'), 'utf8'))
.toBe('export const host = true\n')
expect(readFileSync(join(root, 'packages/remote-only/lib/typert.remote-client.js'), 'utf8'))
.toBe('export const remoteOnly = true\n')
expect(existsSync(join(root, 'packages/ignored/lib/typert.host.js'))).toBe(false)
})
})
async function workspace(): Promise<string> {

View File

@@ -201,6 +201,53 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
expect(batched).toEqual(direct)
})
it('discovers an explicitly keyed service implementation without a Context merge', () => {
const root = copyFixture('explicit-service-')
addExplicitServicePackage(root, 'service detached')
const analyzer = new WorkspaceAnalyzer({ root })
expect(analyzer.discoverPackages()).toContainEqual({
package: '@fixture/explicit-service',
root: 'packages/explicit-service',
faces: ['host'],
})
const model = new WorkspaceAnalyzer({ root, packages: ['@fixture/explicit-service'] }).analyze()
const service = model.faces[0]?.packages[0]?.services[0]
expect(service).toMatchObject({ key: 'detached', export: { name: 'DetachedService' } })
})
it('prefers an explicitly keyed implementation over its protocol Context merge', () => {
const root = copyFixture('explicit-service-protocol-')
addExplicitServicePackage(root, 'service detached', true)
const model = new WorkspaceAnalyzer({
root,
packages: ['@fixture/explicit-service'],
}).analyze()
const service = model.faces[0]?.packages[0]?.services[0]
expect(service).toMatchObject({
key: 'detached',
export: { name: 'DetachedService' },
location: { file: 'packages/explicit-service/src/index.ts' },
})
})
it('rejects an explicit service implementation without one valid key', () => {
const missing = copyFixture('explicit-service-missing-')
addExplicitServicePackage(missing, 'service')
expect(() => new WorkspaceAnalyzer({
root: missing,
packages: ['@fixture/explicit-service'],
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
const invalid = copyFixture('explicit-service-invalid-')
addExplicitServicePackage(invalid, 'service bad/key')
expect(() => new WorkspaceAnalyzer({
root: invalid,
packages: ['@fixture/explicit-service'],
}).analyze()).toThrow('@typert service requires exactly one nonempty Cordis service key')
})
it('indexes authored top-level exports without promoting them to graph roots', () => {
const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations()
const agent = declarations.find(declaration => declaration.name === 'Agent')
@@ -1178,6 +1225,57 @@ function addSameFacePackage(root: string, specifier: string, importedName: strin
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
}
function addExplicitServicePackage(root: string, annotation: string, withProtocol = false): void {
const packageRoot = join(root, 'packages/explicit-service')
mkdirSync(join(packageRoot, 'src'), { recursive: true })
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
name: '@fixture/explicit-service',
private: true,
type: 'module',
exports: {
'.': {
types: './lib/types/index.d.ts',
default: './lib/index.js',
},
},
}, null, 2))
writeFileSync(join(packageRoot, 'tsconfig.json'), JSON.stringify({
extends: '../../tsconfig.base.json',
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
include: ['src'],
}, null, 2))
if (withProtocol) {
writeFileSync(join(packageRoot, 'src/types.ts'), [
'/** Public detached Service protocol. */',
'export interface DetachedProtocol {',
' /** Report protocol readiness. */',
' ready(): boolean',
'}',
"declare module 'cordis' {",
' interface Context { detached: DetachedProtocol }',
'}',
'',
].join('\n'))
}
writeFileSync(join(packageRoot, 'src/index.ts'), [
"import { Service } from 'cordis'",
...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []),
'/**',
' * Service implementation discovered independently of its protocol package.',
` * @typert ${annotation}`,
' */',
'export class DetachedService extends Service {',
' /** Report readiness. */',
' ready(): boolean { return true }',
'}',
'',
].join('\n'))
const aggregatePath = join(root, 'tsconfig.host.json')
const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] }
aggregate.references.push({ path: './packages/explicit-service' })
writeFileSync(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
}
describe('FaceModelEmitter', { timeout: 60_000 }, () => {
it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => {
const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze()

View File

@@ -135,6 +135,11 @@ 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)
}
}
return manifest as unknown as TypertContribution
}
@@ -184,6 +189,88 @@ function requireTypes(pkgName: string, value: unknown, subject: string): void {
}
}
function requireInvocation(pkgName: string, value: unknown): void {
const invocation = requireObject(pkgName, value, 'invocation')
for (const key of ['id', 'service', 'namespace', 'method'] as const) {
requireString(pkgName, invocation, key, 'invocation')
}
const id = invocation.id as string
const receiver = requireObject(pkgName, invocation.invocation, `invocation "${id}" receiver`)
if (receiver.kind === 'context') {
requireString(pkgName, receiver, 'context', `invocation "${id}" Context receiver`)
requireString(pkgName, receiver, 'wire', `invocation "${id}" Context receiver`)
requireStrictCodec(pkgName, receiver.codec, `invocation "${id}" Context codec`)
} else if (receiver.kind !== 'direct') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" receiver kind must be "direct" or "context"`)
}
const wires = new Set<string>()
const parameters = new Map<string, Record<string, unknown>>()
let lookupCount = 0
for (const valueParameter of requireArray(pkgName, invocation.parameters, `invocation "${id}" parameters`)) {
const parameter = requireObject(pkgName, valueParameter, `invocation "${id}" parameter`)
requireString(pkgName, parameter, 'name', `invocation "${id}" parameter`)
requireString(pkgName, parameter, 'wire', `invocation "${id}" parameter`)
const wire = parameter.wire as string
if (wires.has(wire)) {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats wire field "${wire}"`)
}
wires.add(wire)
if (parameter.source === 'lookup') {
lookupCount += 1
requireString(pkgName, parameter, 'lookup', `invocation "${id}" lookup parameter`)
} else if (parameter.source === 'json') {
if (parameter.lookup !== undefined) {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" JSON parameter declares a lookup`)
}
} else {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" parameter source must be "json" or "lookup"`)
}
parameters.set(wire, parameter)
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
}
if (invocation.scope !== undefined) {
if (receiver.kind !== 'direct') {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`)
}
const scope = requireObject(pkgName, invocation.scope, `invocation "${id}" scope`)
requireString(pkgName, scope, 'context', `invocation "${id}" scope`)
requireString(pkgName, scope, 'wire', `invocation "${id}" scope`)
const parameter = parameters.get(scope.wire as string)
if (lookupCount !== 1 || parameter?.source !== 'lookup' || parameter.lookup !== scope.context) {
throw new Error(
`typert-loader: ${pkgName} invocation "${id}" scope wire "${scope.wire as string}" must select its only lookup parameter`,
)
}
}
if (receiver.kind === 'context' && wires.has(receiver.wire as string)) {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" repeats Context wire field "${receiver.wire as string}"`)
}
requireStrictCodec(pkgName, invocation.result, `invocation "${id}" result codec`)
if (invocation.sourceLocation !== undefined) {
const location = requireObject(pkgName, invocation.sourceLocation, `invocation "${id}" sourceLocation`)
requireString(pkgName, location, 'file', `invocation "${id}" sourceLocation`)
for (const key of ['line', 'column'] as const) {
if (!Number.isInteger(location[key]) || (location[key] as number) < 1) {
throw new Error(`typert-loader: ${pkgName} invocation "${id}" sourceLocation.${key} must be a positive integer`)
}
}
}
}
function requireStrictCodec(pkgName: string, value: unknown, subject: string): void {
const codec = requireObject(pkgName, value, subject)
if (codec.mode !== 'strict') {
throw new Error(`typert-loader: ${pkgName} ${subject} must use a strict codec`)
}
requireString(pkgName, codec, 'typeSymbol', subject)
if (typeof codec.schema !== 'object'
|| codec.schema === null
|| !('_zod' in codec.schema)
|| typeof (codec.schema as { parse?: unknown }).parse !== 'function') {
throw new Error(`typert-loader: ${pkgName} ${subject} is not backed by a zod v4 schema`)
}
}
/**
* Scan current Loader entries during activation, then follow entry mounts and
* unmounts for this plugin's lifetime.
@@ -202,7 +289,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
const configured = new Set((config as ResolvedConfig).packages)
// Registered contributions by entry name; the disposer withdraws the entry's registration.
const registered = new Map<string, () => void>()
const registered = new Map<string, () => Promise<void>>()
// In-flight import/register tasks by entry name.
const pending = new Map<string, Promise<void>>()
// Artifact paths by package name. Negative verdicts (unresolvable specifier —
@@ -279,7 +366,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
const dispose = registered.get(entryName)
if (dispose !== undefined) {
registered.delete(entryName)
dispose()
return dispose()
}
return undefined
}

View File

@@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -8,6 +9,7 @@ import Loader from '@cordisjs/plugin-loader'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import * as typertLoader from '@deepseek-ai/dsh-typert-loader'
import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader'
import { z } from 'zod'
let root: string | undefined
let context: Context | undefined
@@ -63,12 +65,45 @@ function typertSource(pkgName: string, entryName: string): string {
].join('\n')
}
function invocationTypertSource(pkgName: string): string {
return [
'import { z } from \'zod\'',
'const Text = z.string()',
'export const TYPERT = {',
` package: '${pkgName}',`,
' face: \'host\',',
' schemas: [],',
' model: { services: [], events: [], objects: [] },',
' invocations: [{',
` id: '${pkgName}#goals/create',`,
' service: \'goals\', namespace: \'goals\', method: \'create\',',
' invocation: { kind: \'direct\' },',
' parameters: [{',
' name: \'request\', wire: \'request\', source: \'json\',',
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`,
' }],',
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`,
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
' }],',
'}',
'',
].join('\n')
}
/** Boot a real Loader over a fixture root; plugin modules resolve from its node_modules. */
async function boot(): Promise<Context> {
context = new Context()
context.baseUrl = pathToFileURL(join(root as string, 'cordis.yml')).href
await context.plugin(TypertRegistry)
await context.plugin(Loader)
const fixtureRequire = createRequire(context.baseUrl)
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
const module: unknown = await import(pathToFileURL(fixtureRequire.resolve(specifier)).href)
return module
},
} as unknown as NonNullable<typeof context.loader.internal>
// zod must be resolvable from the fixture packages; link the workspace copy.
await mkdir(join(root as string, 'node_modules'), { recursive: true })
return context
@@ -105,6 +140,33 @@ describe('typert loader', () => {
expect(ctx.typert.getPackage('@fixture/nested')).toBeUndefined()
})
it('registers a strict invocation into the local registry and withdraws it with the loader', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await linkZod(root)
await writePackage(root, '@fixture/invocation', {
typertSource: invocationTypertSource('@fixture/invocation'),
})
const ctx = await boot()
const fiber = mountTypertLoader(ctx, { packages: ['@fixture/invocation'] })
await fiber
const descriptor = ctx.typert.local.get('goals/create')
expect(descriptor).toMatchObject({
id: '@fixture/invocation#goals/create',
invocation: { kind: 'direct' },
parameters: [{ wire: 'request', source: 'json' }],
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
})
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
if (descriptor?.parameters[0]?.codec.mode === 'strict') {
expect(descriptor.parameters[0].codec.schema.parse('request')).toBe('request')
}
await fiber.dispose()
expect(ctx.typert.local.get('goals/create')).toBeUndefined()
})
it('fails loud when an explicit package is absent or has no Typert export', LOADER_TEST_TIMEOUT, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
await writePackage(root, '@fixture/plain')
@@ -427,8 +489,156 @@ describe('validateTypertManifest', () => {
model: { ...complete.model, objects: [{ ...complete.model.objects[0], exportName: '' }] },
})).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)
const descriptor = strictInvocation()
const manifest = { ...legacy, invocations: [descriptor] }
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
const scoped = {
...descriptor,
scope: { context: 'agent', wire: 'agentId' },
parameters: [{
name: 'agent',
wire: 'agentId',
source: 'lookup',
lookup: 'agent',
codec: strictCodec('pkg#AgentId'),
}, ...descriptor.parameters],
}
expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations)
.toEqual([scoped])
expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} }))
.toThrow('TYPERT.invocations must be an array')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...descriptor, invocation: { kind: 'future' } }],
})).toThrow('receiver kind must be "direct" or "context"')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
})).toThrow('result codec must use a strict codec')
expect(() => validateTypertManifest('pkg', {
...legacy,
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,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], source: 'future' }],
}],
})).toThrow('parameter source must be "json" or "lookup"')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], source: 'lookup' }],
}],
})).toThrow('lookup parameter has a missing or empty lookup')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...descriptor,
parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }],
}],
})).toThrow('JSON parameter declares a lookup')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...descriptor,
parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }],
}],
})).toThrow('repeats wire field "request"')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...descriptor,
invocation: {
kind: 'context',
context: 'agent',
wire: 'request',
codec: strictCodec('pkg#AgentId'),
},
}],
})).toThrow('repeats Context wire field "request"')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...scoped, scope: null }],
})).toThrow('scope must be an object')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...scoped, scope: { wire: 'agentId' } }],
})).toThrow('scope has a missing or empty context')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...scoped, scope: { context: 'agent' } }],
})).toThrow('scope has a missing or empty wire')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...scoped,
invocation: {
kind: 'context',
context: 'agent',
wire: 'scopeId',
codec: strictCodec('pkg#AgentId'),
},
}],
})).toThrow('Context receiver cannot declare a direct scope projection')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{
...scoped,
parameters: [...scoped.parameters, {
name: 'other',
wire: 'otherId',
source: 'lookup',
lookup: 'agent',
codec: strictCodec('pkg#AgentId'),
}],
}],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }],
})).toThrow('must select its only lookup parameter')
expect(() => validateTypertManifest('pkg', {
...legacy,
invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }],
})).toThrow('sourceLocation.line must be a positive integer')
})
})
function strictCodec(typeSymbol: string) {
return { mode: 'strict', typeSymbol, schema: z.string() }
}
function strictInvocation() {
return {
id: 'pkg#goals/create',
service: 'goals',
namespace: 'goals',
method: 'create',
invocation: { kind: 'direct' },
parameters: [{
name: 'request',
wire: 'request',
source: 'json',
codec: strictCodec('pkg#Request'),
}],
result: strictCodec('pkg#Result'),
sourceLocation: { file: 'src/index.ts', line: 1, column: 1 },
}
}
function completeManifest(zodish: object) {
const member = { name: 'member', signature: 'member(): void', kind: 'method' }
const type = { name: 'Value', declaration: 'export interface Value {}' }

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'])

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43
README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-type-meta
English | [中文](README.zh.md)
Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service.
## Remote declarations
- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service.
- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind.
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
## TypeRT protocol
Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteContextMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client API.
Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path.
## Model Experience
None, as this protocol package declares application reflection and registers no model surface.
#### KV Cache effect
No direct effect.
## Known Limitations and Deferred Work
- Decorator markers contain only the method name and direct or Context invocation mode. Parameter, result, lookup, and schema reflection require the TypeRT build pipeline.
- Remote decorators accept only public, non-static instance methods with string names. SRC execution cannot represent overloaded, destructured, defaulted, or rest-parameter signatures.

View File

@@ -0,0 +1,33 @@
# @deepseek-ai/dsh-type-meta
[English](README.md) | 中文
该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。
## Remote 声明
- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。
- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
## TypeRT 协议
业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteContextMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client API 使用的共享运行时形式。
查找包与 Context 包同时负责其契约的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。
## 模型体验
无,因为该协议包声明应用反射,不注册任何模型接口。
#### KV Cache 影响
无直接影响。
## 已知限制与延期工作
- 装饰器标记仅包含方法名,以及直接调用或 Context 调用模式。参数、结果、查找和 schema 反射需要 TypeRT 构建流水线。
- Remote 装饰器只接受具有字符串名称的公开、非静态实例方法。SRC 执行无法表示重载签名,以及包含解构参数、默认参数或剩余参数的方法签名。

View File

@@ -0,0 +1,42 @@
{
"name": "@deepseek-ai/dsh-type-meta",
"description": "Compiler-independent Remote metadata and TypeRT provider protocols",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,223 @@
/**
* Remote decorators and explicit Gateway bindings backed only by private
* module state. Strict reflection remains a TypeRT compiler responsibility.
* @module @deepseek-ai/dsh-type-meta
*/
import type { TypeRTContextMap } from './types.ts'
export type {
InvocationDescriptor,
InvocationParameterDescriptor,
InvocationSourceLocation,
TypeRTClientContextBinder,
TypeRTCodec,
TypeRTContext,
TypeRTContextMap,
TypeRTContextRegistry,
TypeRTContextWire,
TypeRTDisposer,
TypeRTHostContextProvider,
TypeRTLocalRegistry,
TypeRTLookup,
TypeRTLookupHost,
TypeRTLookupMap,
TypeRTLookupProvider,
TypeRTLookupRegistry,
TypeRTLookupWire,
TypeRTRemoteContextApi,
TypeRTRemoteContextMap,
TypeRTRemoteContextNamespace,
TypeRTRemoteContribution,
TypeRTRemoteMap,
TypeRTRemoteNamespace,
TypeRTRemoteNamespaceMap,
TypeRTRemoteRegistry,
TypeRTRegistryChange,
TypeRTRegistryListener,
TypeRTSchema,
TypeRTService,
} from './types.ts'
/** Options for an explicit Service-to-Gateway binding. */
export interface TypeRTGatewayBindingOptions {
/** Wire namespace; defaults to the Cordis service key. */
readonly namespace?: string
}
/** Visible declaration that one Service participates in TypeRT Gateway export. */
export interface TypeRTGatewayBinding<Service extends object = object> {
readonly service: Service
readonly serviceKey: string
readonly namespace: string
}
/** Invocation mode recorded by a Remote method decorator. */
export type RemoteInvocationMarker =
| { readonly kind: 'direct' }
| { readonly kind: 'context'; readonly context: string }
/** One decorator marker discovered for a live Service instance. */
export interface RemoteMethodMarker {
/** Public instance method carrying the implementation. */
readonly method: string
/** Endpoint method when it differs from the implementation member. */
readonly exportName?: string
readonly invocation: RemoteInvocationMarker
}
type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
) => void
interface RemoteInitializerContext<This extends object> {
readonly private: boolean
readonly static: boolean
readonly name: string | symbol
addInitializer(initializer: (this: This) => void): void
}
interface StoredRemoteMethodMarker {
readonly exportName?: string
readonly invocation: RemoteInvocationMarker
}
const markers = new WeakMap<object, Map<string, StoredRemoteMethodMarker>>()
/**
* Bind one visible Service field to a Cordis key and Remote namespace.
* @param service - owning Service instance, normally `this`.
* @param serviceKey - exact Cordis service key.
* @param options - optional distinct wire namespace.
* @returns a frozen, inspectable binding with no compiler-injected metadata.
*/
export function bindTypeRTGateway<Service extends object>(
service: Service,
serviceKey: string,
options: TypeRTGatewayBindingOptions = {},
): TypeRTGatewayBinding<Service> {
validateName('service key', serviceKey)
const namespace = options.namespace ?? serviceKey
validateName('namespace', namespace)
return Object.freeze({ service, serviceKey, namespace })
}
/**
* Mark one public instance method as a direct Remote invocation.
* @param _method - decorated method; retained only by the class itself.
* @param context - standard decorator context used to schedule private marking.
*/
export function Remote<This extends object, Args extends unknown[], Result>(
_method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void
/**
* Mark one public instance method under a distinct exported method name.
* @param exportName - Remote endpoint method, without a namespace or slash.
* @returns a standard method decorator.
*/
export function Remote(exportName: string): RemoteMethodDecorator
export function Remote<This extends object, Args extends unknown[], Result>(
methodOrExportName: string | ((this: This, ...args: Args) => Result),
context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void | RemoteMethodDecorator {
if (typeof methodOrExportName === 'string') {
validateName('Remote export name', methodOrExportName)
return function <DecoratorThis extends object, DecoratorArgs extends unknown[], DecoratorResult>(
_method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult,
decoratorContext: ClassMethodDecoratorContext<
DecoratorThis,
(this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult
>,
): void {
addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName)
}
}
if (context === undefined) throw new TypeError('type-meta: Remote decorator context is missing')
addMarkerInitializer(context, { kind: 'direct' })
}
/**
* Create a decorator for a method resolved from one scoped Remote Context.
* @param key - merge-declared Context key.
* @param exportName - optional Remote export name; defaults to the method name.
* @returns a standard method decorator that records only private module state.
*/
export function RemoteContext(
key: Extract<keyof TypeRTContextMap, string>,
exportName?: string,
): RemoteMethodDecorator {
validateName('Context key', key)
if (exportName !== undefined) validateName('Remote export name', exportName)
return function <This extends object, Args extends unknown[], Result>(
_method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): void {
addMarkerInitializer(context, { kind: 'context', context: key }, exportName)
}
}
/**
* Read Remote markers attached to a live Service by decorator initializers.
* The returned snapshot cannot mutate the private marker table.
* @param service - live Service instance.
* @returns markers in class declaration order.
*/
export function remoteMethods(service: object): readonly RemoteMethodMarker[] {
const prototype = Object.getPrototypeOf(service) as object | null
if (prototype === null) return []
return [...(markers.get(prototype) ?? [])].map(([method, marker]) => ({ method, ...marker }))
}
function addMarkerInitializer<This extends object>(
context: RemoteInitializerContext<This>,
invocation: RemoteInvocationMarker,
exportName?: string,
): void {
if (context.private || context.static || typeof context.name !== 'string') {
throw new TypeError('type-meta: Remote decorators require a public instance method with a string name')
}
const method = context.name
context.addInitializer(function (this: This) {
const prototype = Object.getPrototypeOf(this) as object | null
if (prototype === null) {
throw new TypeError(`type-meta: cannot mark Remote method "${method}" on an object without a prototype`)
}
mark(prototype, method, invocation, exportName)
})
}
function mark(
prototype: object,
method: string,
invocation: RemoteInvocationMarker,
exportName?: string,
): void {
let table = markers.get(prototype)
if (table === undefined) {
table = new Map()
markers.set(prototype, table)
}
const marker: StoredRemoteMethodMarker = {
...(exportName === undefined || exportName === method ? {} : { exportName }),
invocation: Object.freeze(invocation),
}
const current = table.get(method)
if (current !== undefined) {
if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return
throw new Error(`type-meta: Remote method "${method}" has conflicting invocation markers`)
}
table.set(method, Object.freeze(marker))
}
function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMarker): boolean {
return left.kind === right.kind
&& (left.kind === 'direct' || (right.kind === 'context' && left.context === right.context))
}
function validateName(subject: string, value: string): void {
if (value.length === 0 || value.includes('/')) {
throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`)
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-type-meta`.
* @module @deepseek-ai/dsh-type-meta/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta'
/** Cordis companion plugin name. */
export const name = 'type-meta-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: decorators retain private immutable declarations and
* bindings are frozen values with no independent event stream to cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,358 @@
/**
* Compiler-independent TypeRT protocol shared by business packages, generated
* Remote artifacts, the Host Gateway, and Client API implementations.
* @module @deepseek-ai/dsh-type-meta/types
*/
import type { Context } from 'cordis'
declare const LOOKUP_HOST: unique symbol
declare const LOOKUP_WIRE: unique symbol
declare const CONTEXT_WIRE: unique symbol
/** Type-level association between a Host object and its wire identity. */
export interface TypeRTLookup<Host, Wire> {
readonly [LOOKUP_HOST]: Host
readonly [LOOKUP_WIRE]: Wire
}
/** Extract the Host object associated with one lookup declaration. */
export type TypeRTLookupHost<Lookup> = Lookup extends TypeRTLookup<infer Host, infer _Wire> ? Host : never
/** Extract the wire identity associated with one lookup declaration. */
export type TypeRTLookupWire<Lookup> = Lookup extends TypeRTLookup<infer _Host, infer Wire> ? Wire : never
/** Type-level association between a scoped Context kind and its wire identity. */
export interface TypeRTContext<Wire> {
readonly [CONTEXT_WIRE]: Wire
}
/** Extract the wire identity associated with one scoped Context declaration. */
export type TypeRTContextWire<ContextType> = ContextType extends TypeRTContext<infer Wire> ? Wire : never
/** Merge-extensible Host object lookup declarations. */
export interface TypeRTLookupMap {}
/** Merge-extensible scoped Context declarations. */
export interface TypeRTContextMap {}
/** Merge-extensible direct Remote method signatures generated for consumers. */
export interface TypeRTRemoteMap {}
/** Merge-extensible scoped Remote method signatures generated for consumers. */
export interface TypeRTRemoteContextMap {}
/**
* Resolve one direct Remote namespace from the generated flat endpoint map.
* @template Namespace - wire namespace before the endpoint slash.
*/
export type TypeRTRemoteNamespace<Namespace extends string> = {
[Endpoint in keyof TypeRTRemoteMap as Endpoint extends `${Namespace}/${infer Method}`
? Method
: never]: TypeRTRemoteMap[Endpoint]
}
/**
* Resolve one scoped Remote namespace across every generated Context kind.
* The calling Cordis Context supplies the concrete identity at runtime.
* @template Namespace - wire namespace between the Context prefix and method.
*/
export type TypeRTRemoteContextNamespace<
Namespace extends string,
ContextKey extends string = string,
> = {
[Endpoint in keyof TypeRTRemoteContextMap as Endpoint extends `${ContextKey}:${Namespace}/${infer Method}`
? Method
: never]: TypeRTRemoteContextMap[Endpoint]
}
type TypeRTRemoteContextNamespaceKey<
ContextKey extends string,
Endpoint = keyof TypeRTRemoteContextMap,
> = Endpoint extends `${ContextKey}:${infer Namespace}/${string}` ? Namespace : never
/** Generated scoped Remote namespaces available to one Context kind. */
export type TypeRTRemoteContextApi<ContextKey extends string> = {
[Namespace in TypeRTRemoteContextNamespaceKey<ContextKey>]:
TypeRTRemoteContextNamespace<Namespace, ContextKey>
}
/** Merge-extensible direct namespace surface generated for Client API services. */
export interface TypeRTRemoteNamespaceMap {}
/** Awaitable disposer returned by Cordis-owned TypeRT registrations. */
export type TypeRTDisposer = () => Promise<void>
type StringKeyOf<Value> = Extract<keyof Value, string>
/** Minimal runtime-schema capability carried by strict generated codecs. */
export interface TypeRTSchema<Output = unknown> {
/**
* Parse and validate one boundary value.
* @param value - untrusted boundary value.
* @returns the validated value.
*/
parse(value: unknown): Output
}
/** Codec attached to one invocation parameter or result. */
export type TypeRTCodec =
| {
readonly mode: 'strict'
readonly typeSymbol: string
readonly schema: TypeRTSchema
}
| {
readonly mode: 'src-json'
}
/** One ordered business parameter in a Remote invocation. */
export interface InvocationParameterDescriptor {
/** Source-level parameter name. */
readonly name: string
/** Required key in the wire `args` object. */
readonly wire: string
/** Whether the value is JSON or requires a registered Host lookup. */
readonly source: 'json' | 'lookup'
/** Lookup key when `source` is `lookup`. */
readonly lookup?: string
/** Boundary codec for the wire representation. */
readonly codec: TypeRTCodec
}
/** Source position retained for diagnostics from generated definitions. */
export interface InvocationSourceLocation {
readonly file: string
readonly line: number
readonly column: number
}
/** Carrier-independent description of one exported method invocation. */
export interface InvocationDescriptor {
/** Globally stable generated identity. */
readonly id: string
/** Cordis service key owning the method. */
readonly service: string
/** Wire namespace, defaulting to the service key. */
readonly namespace: string
/** Public instance method name. */
readonly method: string
/** Service member invoked when the exported method name is an alias. */
readonly implementation?: string
/** Receiver selection mode. */
readonly invocation:
| { readonly kind: 'direct' }
| {
readonly kind: 'context'
readonly context: string
readonly wire: string
readonly codec: TypeRTCodec
}
/** Optional consuming-Context projection for one direct lookup parameter. */
readonly scope?: {
/** Context kind whose Client binder supplies the identity. */
readonly context: string
/** Lookup parameter wire field replaced by the Context identity. */
readonly wire: string
}
/** Ordered business parameters. */
readonly parameters: readonly InvocationParameterDescriptor[]
/** Codec for the resolved method result. */
readonly result: TypeRTCodec
/** Source declaration used only for diagnostics. */
readonly sourceLocation?: InvocationSourceLocation
}
/** Generated Host contract selected explicitly by a Client assembly. */
export interface TypeRTRemoteContribution {
/** npm package that owns the Remote methods. */
readonly package: string
/** Consumer-side invocation descriptors generated from that package. */
readonly descriptors: readonly InvocationDescriptor[]
}
/** Runtime resolver for one declared Host object lookup. */
export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
/** 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
/**
* Resolve a wire identity to the current live Host object.
* @param id - validated wire identity.
* @returns the live object, or `undefined` when it is unavailable.
*/
resolve(id: Wire): Host | undefined
}
/** Host resolver for one scoped Remote Context kind. */
export interface TypeRTHostContextProvider<Wire = unknown> {
/** Wire field carrying the Context identity. */
readonly wire: string
/** Canonical wire type symbol used by strict generation. */
readonly wireTypeSymbol: string
/**
* Resolve a wire identity to its live scoped Context.
* @param id - validated wire identity.
* @returns the scoped Context, or `undefined` when unavailable.
*/
resolve(id: Wire): Context | undefined
}
/** Client resolver for the identity carried by the calling scoped Context. */
export interface TypeRTClientContextBinder<Wire = unknown> {
/**
* Read the Remote identity represented by a calling Context.
* @param ctx - Context rebound by the Cordis service tracker.
* @returns the wire identity, or `undefined` when the Context has the wrong scope.
*/
identity(ctx: Context): Wire | undefined
}
/** Notification emitted after a TypeRT runtime registry changes. */
export interface TypeRTRegistryChange {
readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context'
readonly key: string
}
/** Listener for one TypeRT runtime registry. */
export type TypeRTRegistryListener = (change: TypeRTRegistryChange) => void
/** Current-environment invocation definitions. */
export interface TypeRTLocalRegistry {
/**
* Look up one invocation by `<namespace>/<method>`.
* @param endpoint - canonical endpoint.
* @returns the live descriptor, or `undefined` when absent.
*/
get(endpoint: string): InvocationDescriptor | undefined
/**
* Report whether a strict definition has existed during this TypeRT Service lifetime.
* @param endpoint - canonical endpoint.
* @returns `true` after the endpoint has been registered at least once, even if withdrawn.
*/
hasSeen(endpoint: string): boolean
/** @returns a registration-order snapshot of local descriptors. */
list(): readonly InvocationDescriptor[]
/**
* Observe later local-definition changes.
* @param listener - synchronous contained observer.
* @returns disposer for this subscription.
*/
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
}
/** Consumer-selected Remote contribution registry. */
export interface TypeRTRemoteRegistry {
/**
* Register one generated contribution for the calling Cordis fiber.
* @param contribution - generated Remote descriptors.
* @returns disposer withdrawing the exact contribution.
*/
register(contribution: TypeRTRemoteContribution): TypeRTDisposer
/**
* Look up one Remote descriptor by endpoint.
* @param endpoint - canonical endpoint.
* @returns the descriptor, or `undefined` when unmounted.
*/
get(endpoint: string): InvocationDescriptor | undefined
/** @returns a registration-order snapshot of Remote descriptors. */
list(): readonly InvocationDescriptor[]
/**
* Observe later Remote contribution changes.
* @param listener - synchronous contained observer.
* @returns disposer for this subscription.
*/
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
}
/** Runtime registry for Host object lookup providers. */
export interface TypeRTLookupRegistry {
/**
* Register one provider under its merge-declared key.
* @param key - lookup key.
* @param provider - owning package's live resolver.
* @returns disposer withdrawing the exact provider.
*/
register<K extends StringKeyOf<TypeRTLookupMap>>(
key: K,
provider: TypeRTLookupProvider<
TypeRTLookupHost<TypeRTLookupMap[K]>,
TypeRTLookupWire<TypeRTLookupMap[K]>
>,
): TypeRTDisposer
/**
* Look up one provider by runtime key.
* @param key - descriptor lookup key.
* @returns the live provider, or `undefined` when absent.
*/
get(key: string): TypeRTLookupProvider | undefined
/** @returns a snapshot of registered provider keys. */
keys(): readonly string[]
/**
* Observe later lookup changes.
* @param listener - synchronous contained observer.
* @returns disposer for this subscription.
*/
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
}
/** Runtime registry for Host Context resolvers and Client Context binders. */
export interface TypeRTContextRegistry {
/**
* Register a Host Context resolver.
* @param key - merge-declared Context key.
* @param provider - owning package's Host resolver.
* @returns disposer withdrawing the exact provider.
*/
registerHost<K extends StringKeyOf<TypeRTContextMap>>(
key: K,
provider: TypeRTHostContextProvider<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer
/**
* Register a Client Context identity binder.
* @param key - merge-declared Context key.
* @param binder - Client scope identity resolver.
* @returns disposer withdrawing the exact binder.
*/
registerClient<K extends StringKeyOf<TypeRTContextMap>>(
key: K,
binder: TypeRTClientContextBinder<TypeRTContextWire<TypeRTContextMap[K]>>,
): TypeRTDisposer
/**
* Look up a Host Context resolver.
* @param key - descriptor Context key.
* @returns the provider, or `undefined` when absent.
*/
getHost(key: string): TypeRTHostContextProvider | undefined
/**
* Look up a Client Context binder.
* @param key - descriptor Context key.
* @returns the binder, or `undefined` when absent.
*/
getClient(key: string): TypeRTClientContextBinder | undefined
/**
* Observe later Context provider changes.
* @param listener - synchronous contained observer.
* @returns disposer for this subscription.
*/
subscribe(listener: TypeRTRegistryListener): TypeRTDisposer
}
/** Minimal TypeRT runtime consumed through dependency inversion. */
export interface TypeRTService {
readonly local: TypeRTLocalRegistry
readonly remotes: TypeRTRemoteRegistry
readonly lookups: TypeRTLookupRegistry
readonly contexts: TypeRTContextRegistry
}
declare module 'cordis' {
interface Context {
typert: TypeRTService
}
}

View File

@@ -0,0 +1,29 @@
import {
bindTypeRTGateway,
Remote,
RemoteContext,
remoteMethods,
} from '@deepseek-ai/dsh-type-meta'
class Goals {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
@Remote
create(value: string): string {
return value
}
@RemoteContext('agent')
scoped(value: string): string {
return value
}
}
const methods = remoteMethods(new Goals())
const actual = JSON.stringify(methods)
const expected = JSON.stringify([
{ method: 'create', invocation: { kind: 'direct' } },
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
])
if (actual !== expected) throw new Error(`unexpected Remote declarations: ${actual}`)
process.stdout.write(actual)

View File

@@ -0,0 +1,132 @@
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
bindTypeRTGateway,
Remote,
RemoteContext,
remoteMethods,
type TypeRTContext,
} from '@deepseek-ai/dsh-type-meta'
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTContextMap {
metaFixture: TypeRTContext<string>
}
}
describe('type-meta Remote declarations', () => {
it('executes standard decorator syntax through the Vitest source transform', () => {
class Goals {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
@Remote
create(value: string): string {
return value
}
@RemoteContext('metaFixture')
scoped(value: string): string {
return value
}
}
const goals = new Goals()
expect(remoteMethods(goals)).toEqual([
{ method: 'create', invocation: { kind: 'direct' } },
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
])
})
it('executes standard decorator syntax through the TSX source launcher', () => {
const fixture = fileURLToPath(new URL('./fixtures/source-launch.ts', import.meta.url))
const output = execFileSync(process.execPath, ['--import', 'tsx/esm', fixture], { encoding: 'utf8' })
expect(JSON.parse(output)).toEqual([
{ method: 'create', invocation: { kind: 'direct' } },
{ method: 'scoped', invocation: { kind: 'context', context: 'agent' } },
])
})
it('keeps decorator markers in private module state', () => {
class Goals {
readonly typertGateway = bindTypeRTGateway(this, 'goals')
create(agent: object, request: object): object {
return { agent, request }
}
scoped(request: object): object {
return request
}
}
const initializers: Array<(this: Goals) => void> = []
Remote(
Reflect.get(Goals.prototype, 'create') as (this: Goals, ...args: unknown[]) => unknown,
methodContext('create', initializers),
)
RemoteContext('metaFixture')(
Reflect.get(Goals.prototype, 'scoped') as (this: Goals, ...args: unknown[]) => unknown,
methodContext('scoped', initializers),
)
const goals = new Goals()
for (const initialize of initializers) initialize.call(goals)
expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' })
expect(Object.isFrozen(goals.typertGateway)).toBe(true)
expect(remoteMethods(goals)).toEqual([
{ method: 'create', invocation: { kind: 'direct' } },
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
])
expect(Reflect.ownKeys(Goals)).toEqual(['length', 'name', 'prototype'])
expect(Reflect.ownKeys(Goals.prototype)).toEqual(['constructor', 'create', 'scoped'])
})
it('keeps markers idempotent across instances and returns detached snapshots', () => {
class Service {
run(value: string): string {
return value
}
}
const initializers: Array<(this: Service) => void> = []
Remote(
Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown,
methodContext('run', initializers),
)
const first = new Service()
const second = new Service()
for (const initialize of initializers) {
initialize.call(first)
initialize.call(second)
}
const snapshot = remoteMethods(first)
expect(remoteMethods(second)).toEqual(snapshot)
;(snapshot as unknown as { method: string }[])[0]!.method = 'changed'
expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }])
})
it('rejects ambiguous binding names', () => {
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
})
})
function methodContext<This extends object>(
name: string,
initializers: Array<(this: This) => void>,
): ClassMethodDecoratorContext<This, (this: This, ...args: unknown[]) => unknown> {
return {
kind: 'method',
name,
static: false,
private: false,
metadata: {},
access: {
has: object => name in object,
get: object => (object as Record<string, unknown>)[name] as (this: This, ...args: unknown[]) => unknown,
},
addInitializer: (initializer) => { initializers.push(initializer) },
}
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}