fix(typert): address remote gateway review
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
|
||||||
2026-08-02-typert-remote-method-calls.md: c810a221a23549f3e17e25bd40fcc1fc0f9ec868
|
2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48
|
||||||
2026-08-02-typert-remote-method-calls.zh.md: 38e3ca286dad98665269697f49fba731346e665e
|
2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ SRC supports local source startup. The `WeakMap` records created by `@Remote` an
|
|||||||
|
|
||||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||||
|
|
||||||
A signature that SRC cannot resolve unambiguously fails when the Service mounts. It does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types.
|
A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types.
|
||||||
|
|
||||||
LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors.
|
LIB supports CI, releases, and the prerequisite Web build. TypeRT scans the complete Host project and checks Remote decorators, explicit bindings, service keys, endpoint conflicts, lookup/Context declarations, public-symbol reachability, JSON codecs, result codecs, and that a reserved final `signal` parameter has the global `AbortSignal` type, then generates strict descriptors.
|
||||||
|
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ SRC 面向本地源码启动。`@Remote` 和 `@RemoteContext()` 的 WeakMap 记
|
|||||||
|
|
||||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||||
|
|
||||||
SRC 无法明确解析的签名在 Service 挂载时失败。对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 和复杂类型不做猜测。
|
SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。
|
||||||
|
|
||||||
LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。
|
LIB 面向 CI、发布和 Web 前置构建。TypeRT 扫描完整 Host project,检查 Remote decorator、显式 binding、service key、endpoint 冲突、lookup/Context 声明、公共符号可达性、JSON codec、结果 codec,以及保留的最后一个 `signal` 参数是否具有全局 `AbortSignal` 类型,并生成严格 descriptor。
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,11 @@ class ClientApiService extends Service implements ClientApi {
|
|||||||
const record = this.scoped.get(namespace)
|
const record = this.scoped.get(namespace)
|
||||||
if (record !== undefined) {
|
if (record !== undefined) {
|
||||||
for (const method of methods) record.service.assertMethodAvailable(method)
|
for (const method of methods) record.service.assertMethodAvailable(method)
|
||||||
} else if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
} else {
|
||||||
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
for (const method of methods) ScopedRemoteNamespace.assertMethodAvailable(namespace, method)
|
||||||
|
if (this.ownerCtx.reflect.props[namespace] !== undefined) {
|
||||||
|
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,11 +146,18 @@ class ClientApiService extends Service implements ClientApi {
|
|||||||
private install(descriptor: InvocationDescriptor): () => void {
|
private install(descriptor: InvocationDescriptor): () => void {
|
||||||
const token: MountToken = { active: true, abort: new AbortController() }
|
const token: MountToken = { active: true, abort: new AbortController() }
|
||||||
const installed: (() => void)[] = []
|
const installed: (() => void)[] = []
|
||||||
if (descriptor.invocation.kind === 'direct') {
|
try {
|
||||||
installed.push(this.installDirect(descriptor, token))
|
if (descriptor.invocation.kind === 'direct') {
|
||||||
|
installed.push(this.installDirect(descriptor, token))
|
||||||
|
}
|
||||||
|
const projection = scopedProjection(descriptor)
|
||||||
|
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
||||||
|
} catch (error) {
|
||||||
|
token.active = false
|
||||||
|
for (const dispose of installed.reverse()) dispose()
|
||||||
|
token.abort.abort()
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
const projection = scopedProjection(descriptor)
|
|
||||||
if (projection !== undefined) installed.push(this.installScoped(descriptor, projection, token))
|
|
||||||
return () => {
|
return () => {
|
||||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||||
if (!token.active) return
|
if (!token.active) return
|
||||||
@@ -192,19 +202,19 @@ class ClientApiService extends Service implements ClientApi {
|
|||||||
): () => void {
|
): () => void {
|
||||||
let namespace = this.scoped.get(descriptor.namespace)
|
let namespace = this.scoped.get(descriptor.namespace)
|
||||||
if (namespace === undefined) {
|
if (namespace === undefined) {
|
||||||
namespace = {
|
const service = new ScopedRemoteNamespace(
|
||||||
service: new ScopedRemoteNamespace(
|
this.ownerCtx,
|
||||||
this.ownerCtx,
|
descriptor.namespace,
|
||||||
descriptor.namespace,
|
(current, currentProjection, currentToken, caller, args) =>
|
||||||
(current, currentProjection, currentToken, caller, args) =>
|
this.invoke(current, currentProjection, currentToken, caller, args),
|
||||||
this.invoke(current, currentProjection, currentToken, caller, args),
|
)
|
||||||
),
|
service.install(descriptor, projection, token)
|
||||||
tokens: new Map(),
|
namespace = { service, tokens: new Map() }
|
||||||
}
|
|
||||||
this.scoped.set(descriptor.namespace, namespace)
|
this.scoped.set(descriptor.namespace, namespace)
|
||||||
|
} else {
|
||||||
|
namespace.service.install(descriptor, projection, token)
|
||||||
}
|
}
|
||||||
namespace.tokens.set(descriptor.method, token)
|
namespace.tokens.set(descriptor.method, token)
|
||||||
namespace.service.install(descriptor, projection, token)
|
|
||||||
return () => {
|
return () => {
|
||||||
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
/* v8 ignore next -- duplicate live methods are rejected before installation, so no newer token can replace this one. */
|
||||||
if (namespace.tokens.get(descriptor.method) !== token) return
|
if (namespace.tokens.get(descriptor.method) !== token) return
|
||||||
@@ -275,6 +285,12 @@ class ScopedRemoteNamespace extends Service {
|
|||||||
private readonly ownerCtx: Context
|
private readonly ownerCtx: Context
|
||||||
private readonly methods = new Set<string>()
|
private readonly methods = new Set<string>()
|
||||||
|
|
||||||
|
static assertMethodAvailable(namespace: string, method: string): void {
|
||||||
|
if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) {
|
||||||
|
throw new Error(`client api: scoped method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -285,6 +301,7 @@ class ScopedRemoteNamespace extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertMethodAvailable(method: string): void {
|
assertMethodAvailable(method: string): void {
|
||||||
|
ScopedRemoteNamespace.assertMethodAvailable(this.name, method)
|
||||||
if (method in this) {
|
if (method in this) {
|
||||||
throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`)
|
throw new Error(`client api: scoped method ${JSON.stringify(`${this.name}/${method}`)} conflicts with its namespace service`)
|
||||||
}
|
}
|
||||||
@@ -311,6 +328,8 @@ class ScopedRemoteNamespace extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx'])
|
||||||
|
|
||||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||||
return `${descriptor.namespace}/${descriptor.method}`
|
return `${descriptor.namespace}/${descriptor.method}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,24 @@ describe('Client TypeRT API', () => {
|
|||||||
await disposeMultipleScoped()
|
await disposeMultipleScoped()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rolls back direct projection when scoped installation fails', async () => {
|
||||||
|
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||||
|
const descriptor: InvocationDescriptor = {
|
||||||
|
...directDescriptor(),
|
||||||
|
id: '@fixture/goals#fresh/remove',
|
||||||
|
namespace: 'fresh',
|
||||||
|
method: 'remove',
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const packageName of ['@fixture/first-attempt', '@fixture/second-attempt']) {
|
||||||
|
expect(() => ctx.api.mount({ package: packageName, descriptors: [descriptor] }))
|
||||||
|
.toThrow('conflicts with its namespace service')
|
||||||
|
expect((ctx.api as unknown as Record<string, unknown>).fresh).toBeUndefined()
|
||||||
|
expect(ctx.get('fresh')).toBeUndefined()
|
||||||
|
expect(ctx.typert.remotes.list()).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||||
const direct = directDescriptor()
|
const direct = directDescriptor()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||||
"@jridgewell/gen-mapping": "^0.3.13",
|
"@jridgewell/gen-mapping": "^0.3.13",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
|
import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta'
|
||||||
import type {
|
import type {
|
||||||
CrossFaceLink,
|
CrossFaceLink,
|
||||||
DocumentationModel,
|
DocumentationModel,
|
||||||
@@ -1171,8 +1172,8 @@ class FaceAnalyzer {
|
|||||||
namespace = value
|
namespace = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"')
|
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must contain only RPC endpoint segment characters')
|
||||||
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"')
|
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must contain only RPC endpoint segment characters')
|
||||||
return { service, namespace, site }
|
return { service, namespace, site }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,7 +1197,7 @@ class FaceAnalyzer {
|
|||||||
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
|
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
|
||||||
const exportName = stringLiteralValue(expression.arguments[0])
|
const exportName = stringLiteralValue(expression.arguments[0])
|
||||||
if (exportName === undefined || !isRemoteSegment(exportName)) {
|
if (exportName === undefined || !isRemoteSegment(exportName)) {
|
||||||
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a nonempty string literal without "/"')
|
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters')
|
||||||
}
|
}
|
||||||
marker = { kind: 'direct', exportName }
|
marker = { kind: 'direct', exportName }
|
||||||
} else if (ts.isCallExpression(expression)
|
} else if (ts.isCallExpression(expression)
|
||||||
@@ -1206,12 +1207,12 @@ class FaceAnalyzer {
|
|||||||
}
|
}
|
||||||
const context = stringLiteralValue(expression.arguments[0])
|
const context = stringLiteralValue(expression.arguments[0])
|
||||||
if (context === undefined || !isRemoteSegment(context)) {
|
if (context === undefined || !isRemoteSegment(context)) {
|
||||||
this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a nonempty string literal without "/"')
|
this.fail(expression.arguments[0] ?? expression, 'RemoteContext() key must be a string literal containing only RPC endpoint segment characters')
|
||||||
}
|
}
|
||||||
const exportArgument = expression.arguments[1]
|
const exportArgument = expression.arguments[1]
|
||||||
const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument)
|
const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument)
|
||||||
if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) {
|
if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) {
|
||||||
this.fail(exportArgument, 'RemoteContext() name must be a nonempty string literal without "/"')
|
this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters')
|
||||||
}
|
}
|
||||||
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
|
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
|
||||||
} else {
|
} else {
|
||||||
@@ -1251,7 +1252,7 @@ class FaceAnalyzer {
|
|||||||
this.fail(declaration, 'TypeRTLookupMap entries must be required properties')
|
this.fail(declaration, 'TypeRTLookupMap entries must be required properties')
|
||||||
}
|
}
|
||||||
const key = memberName(declaration.name)
|
const key = memberName(declaration.name)
|
||||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must be nonempty and must not contain "/"')
|
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must contain only RPC endpoint segment characters')
|
||||||
if (!ts.isTypeReferenceNode(declaration.type)
|
if (!ts.isTypeReferenceNode(declaration.type)
|
||||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|
||||||
|| declaration.type.typeArguments?.length !== 2) {
|
|| declaration.type.typeArguments?.length !== 2) {
|
||||||
@@ -1287,7 +1288,7 @@ class FaceAnalyzer {
|
|||||||
this.fail(declaration, 'TypeRTContextMap entries must be required properties')
|
this.fail(declaration, 'TypeRTContextMap entries must be required properties')
|
||||||
}
|
}
|
||||||
const key = memberName(declaration.name)
|
const key = memberName(declaration.name)
|
||||||
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must be nonempty and must not contain "/"')
|
if (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must contain only RPC endpoint segment characters')
|
||||||
if (!ts.isTypeReferenceNode(declaration.type)
|
if (!ts.isTypeReferenceNode(declaration.type)
|
||||||
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|
||||||
|| declaration.type.typeArguments?.length !== 1) {
|
|| declaration.type.typeArguments?.length !== 1) {
|
||||||
@@ -1331,16 +1332,6 @@ class FaceAnalyzer {
|
|||||||
const type = this.convertType(authoredType)
|
const type = this.convertType(authoredType)
|
||||||
const codecType = this.resolvedRemoteCodecType(authoredType)
|
const codecType = this.resolvedRemoteCodecType(authoredType)
|
||||||
const rootSymbol = this.namedWorkspaceType(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 imports = new Map<SymbolId, RemoteTypeImportModel>()
|
||||||
const visit = (node: ts.Node): void => {
|
const visit = (node: ts.Node): void => {
|
||||||
if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) {
|
if ((ts.isTypeReferenceNode(node) || ts.isImportTypeNode(node))) {
|
||||||
@@ -1355,13 +1346,23 @@ class FaceAnalyzer {
|
|||||||
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
|
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
|
||||||
const imported = this.publicRemoteType(resolved, node)
|
const imported = this.publicRemoteType(resolved, node)
|
||||||
imports.set(imported.symbol, imported)
|
imports.set(imported.symbol, imported)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ts.forEachChild(node, visit)
|
ts.forEachChild(node, visit)
|
||||||
}
|
}
|
||||||
visit(authoredType)
|
visit(authoredType)
|
||||||
|
if (rootSymbol !== undefined) {
|
||||||
|
const imported = this.publicRemoteType(rootSymbol, authoredType)
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
codecType,
|
||||||
|
typeSymbol: `${imported.specifier}#${imported.name}`,
|
||||||
|
imports: [...imports.values()].sort((left, right) =>
|
||||||
|
left.specifier.localeCompare(right.specifier) || left.name.localeCompare(right.name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (requireNamed) this.fail(authoredType, 'lookup and Context wire types must be named public types')
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
codecType,
|
codecType,
|
||||||
@@ -2810,7 +2811,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRemoteSegment(value: string): boolean {
|
function isRemoteSegment(value: string): boolean {
|
||||||
return value.length > 0 && !value.includes('/')
|
return isTypeRTRemoteSegment(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function expressionName(node: ts.Expression): string | undefined {
|
function expressionName(node: ts.Expression): string | undefined {
|
||||||
|
|||||||
@@ -414,8 +414,9 @@ export class FaceModelEmitter {
|
|||||||
invocation: InvocationModel,
|
invocation: InvocationModel,
|
||||||
referenceNames: ReadonlyMap<SymbolId, string>,
|
referenceNames: ReadonlyMap<SymbolId, string>,
|
||||||
): void {
|
): void {
|
||||||
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
|
const key = renderRemotePropertyName(invocation.method)
|
||||||
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length)
|
const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
|
||||||
|
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushMappedRemoteSignature(
|
private pushMappedRemoteSignature(
|
||||||
@@ -914,6 +915,10 @@ function safeIdentifier(name: string): string {
|
|||||||
return `_${normalized}`
|
return `_${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRemotePropertyName(name: string): string {
|
||||||
|
return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : quote(name)
|
||||||
|
}
|
||||||
|
|
||||||
function quote(value: string): string {
|
function quote(value: string): string {
|
||||||
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'`
|
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* @module @deepseek-ai/dsh-typert-generator/tsdown
|
* @module @deepseek-ai/dsh-typert-generator/tsdown
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import { WorkspaceTypertGenerator } from './workspace.ts'
|
import { WorkspaceTypertGenerator } from './workspace.ts'
|
||||||
@@ -103,15 +103,24 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu
|
|||||||
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
|
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
|
||||||
const output = join(packageDir, 'lib')
|
const output = join(packageDir, 'lib')
|
||||||
mkdirSync(output, { recursive: true })
|
mkdirSync(output, { recursive: true })
|
||||||
|
let emittedRemote = false
|
||||||
for (const artifact of artifacts) {
|
for (const artifact of artifacts) {
|
||||||
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js)
|
||||||
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts)
|
||||||
if (artifact.remote !== undefined) {
|
if (artifact.remote !== undefined) {
|
||||||
|
emittedRemote = true
|
||||||
writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js)
|
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'), artifact.remote.dts)
|
||||||
writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap)
|
writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) {
|
||||||
|
for (const file of [
|
||||||
|
'typert.remote-client.js',
|
||||||
|
'typert.remote-client.d.ts',
|
||||||
|
'typert.remote-client.d.ts.map',
|
||||||
|
]) rmSync(join(output, file), { force: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readManifest(packageDir: string): { name?: string; exports?: unknown } {
|
function readManifest(packageDir: string): { name?: string; exports?: unknown } {
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export class WorkspaceTypertGenerator {
|
|||||||
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
|
throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (artifact.remote === undefined) return
|
|
||||||
const remoteExpected = {
|
const remoteExpected = {
|
||||||
types: './lib/typert.remote-client.d.ts',
|
types: './lib/typert.remote-client.d.ts',
|
||||||
default: './lib/typert.remote-client.js',
|
default: './lib/typert.remote-client.js',
|
||||||
@@ -98,16 +97,25 @@ export class WorkspaceTypertGenerator {
|
|||||||
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
|
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
|
||||||
? (manifest.exports as Record<string, unknown>)['./remote']
|
? (manifest.exports as Record<string, unknown>)['./remote']
|
||||||
: undefined
|
: undefined
|
||||||
|
const remoteFiles = [
|
||||||
|
'lib/typert.remote-client.js',
|
||||||
|
'lib/typert.remote-client.d.ts',
|
||||||
|
'lib/typert.remote-client.d.ts.map',
|
||||||
|
]
|
||||||
|
if (artifact.remote === undefined) {
|
||||||
|
if (remoteActual !== undefined || remoteFiles.some(file => files.includes(file))) {
|
||||||
|
throw new TypertAnalysisError(
|
||||||
|
`typert(host): ${artifact.package} publishes Remote artifacts but has no Remote methods`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!sameExport(remoteActual, remoteExpected)) {
|
if (!sameExport(remoteActual, remoteExpected)) {
|
||||||
throw new TypertAnalysisError(
|
throw new TypertAnalysisError(
|
||||||
`typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`,
|
`typert(host): ${artifact.package} must export ./remote as ${JSON.stringify(remoteExpected)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for (const file of [
|
for (const file of remoteFiles) {
|
||||||
'lib/typert.remote-client.js',
|
|
||||||
'lib/typert.remote-client.d.ts',
|
|
||||||
'lib/typert.remote-client.d.ts.map',
|
|
||||||
]) {
|
|
||||||
if (!files.includes(file)) {
|
if (!files.includes(file)) {
|
||||||
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
|
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
|||||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||||
): void
|
): void
|
||||||
|
|
||||||
export function RemoteContext(key: Extract<keyof TypeRTContextMap, string>):
|
export function Remote(exportName: string):
|
||||||
|
<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>, exportName?: string):
|
||||||
<This extends object, Args extends unknown[], Result>(
|
<This extends object, Args extends unknown[], Result>(
|
||||||
method: (this: This, ...args: Args) => Result,
|
method: (this: This, ...args: Args) => Result,
|
||||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||||
|
|||||||
@@ -216,6 +216,91 @@ export type GenericResult = {
|
|||||||
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
|
expect(dispatch?.result.schema.safeParse({ kind: 'ship', value: { cancelled: true } }).success).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('imports public type arguments nested under a named generic boundary', () => {
|
||||||
|
const root = copyFixture()
|
||||||
|
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||||
|
|
||||||
|
/** Generic Remote envelope. */
|
||||||
|
export interface Box<Value> {
|
||||||
|
readonly value: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload reachable only as a generic argument. */
|
||||||
|
export interface BoxPayload {
|
||||||
|
readonly count: number
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
editFile(root, 'packages/remote/src/index.ts', source => source
|
||||||
|
.replace(
|
||||||
|
' RenameGoalResult,\n',
|
||||||
|
' RenameGoalResult,\n Box,\n BoxPayload,\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
|
||||||
|
box(request: Box<BoxPayload>): Box<BoxPayload> {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||||
|
expect(artifact?.remote?.dts).toMatch(/import type \{ [^}]*Box[^}]*BoxPayload[^}]* \} from '@fixture\/remote\/types'/)
|
||||||
|
expect(artifact?.remote?.dts).toContain('box: (request: Box<BoxPayload>) => Promise<Box<BoxPayload>>')
|
||||||
|
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quotes aliased methods in generated namespace interfaces', () => {
|
||||||
|
const root = copyFixture()
|
||||||
|
editFile(root, 'packages/remote/src/index.ts', source => source.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('create-goal')
|
||||||
|
createAlias(request: CreateGoalRequest): CreateGoalResult {
|
||||||
|
return { ref: request.title }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||||
|
expect(artifact?.remote?.dts).toContain("'create-goal': (request: CreateGoalRequest) => Promise<CreateGoalResult>")
|
||||||
|
assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => {
|
||||||
|
const root = copyFixture()
|
||||||
|
editFile(root, 'packages/remote/src/index.ts', source => source.replace(
|
||||||
|
' @Remote\n async create(',
|
||||||
|
` @Remote('${alias}')\n async create(`,
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(() => analyzeRemote(root, false)).toThrow(/RPC endpoint segment characters/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a Remote export after its last Remote method is removed', () => {
|
||||||
|
const root = copyFixture()
|
||||||
|
editFile(root, 'packages/remote/src/index.ts', source => source
|
||||||
|
.replace(' @Remote\n', '')
|
||||||
|
.replace(" @RemoteContext('agent')\n", ''))
|
||||||
|
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||||
|
|
||||||
|
/** @typert schema */
|
||||||
|
export interface RemainingSchema {
|
||||||
|
readonly value: string
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(() => new WorkspaceTypertGenerator(root).generate())
|
||||||
|
.toThrow('publishes Remote artifacts but has no Remote methods')
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: 'missing binding',
|
name: 'missing binding',
|
||||||
@@ -429,9 +514,9 @@ function remotePackage(root: string): {
|
|||||||
return packageModel
|
return packageModel
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFixture(): string {
|
function copyFixture(sourceRoot = fixtureRoot): string {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
|
||||||
cpSync(fixtureRoot, root, { recursive: true })
|
cpSync(sourceRoot, root, { recursive: true })
|
||||||
temporaryRoots.push(root)
|
temporaryRoots.push(root)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@@ -444,10 +529,14 @@ function editFile(root: string, relativePath: string, edit: (source: string) =>
|
|||||||
writeFileSync(path, result)
|
writeFileSync(path, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertRemoteConsumerTypechecks(dts: string | undefined, dtsMap: string | undefined): void {
|
function assertRemoteConsumerTypechecks(
|
||||||
|
dts: string | undefined,
|
||||||
|
dtsMap: string | undefined,
|
||||||
|
sourceRoot = fixtureRoot,
|
||||||
|
): void {
|
||||||
if (dts === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration')
|
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')
|
if (dtsMap === undefined) throw new Error('Remote fixture emitted no Host-for-Client declaration map')
|
||||||
const consumerRoot = copyFixture()
|
const consumerRoot = copyFixture(sourceRoot)
|
||||||
const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts')
|
const declarationPath = join(consumerRoot, 'packages/remote/lib/typert.remote-client.d.ts')
|
||||||
const declarationMapPath = `${declarationPath}.map`
|
const declarationMapPath = `${declarationPath}.map`
|
||||||
const consumerPath = join(consumerRoot, 'consumer.ts')
|
const consumerPath = join(consumerRoot, 'consumer.ts')
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { mkdir } from 'node:fs/promises'
|
|||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { WorkspaceEmitResult } from '../src/workspace.ts'
|
||||||
|
|
||||||
const generated = vi.hoisted(() => vi.fn(() => [
|
const generated = vi.hoisted(() => vi.fn<() => WorkspaceEmitResult[]>(() => [
|
||||||
{
|
{
|
||||||
package: '@deepseek-ai/dsh-tools',
|
package: '@deepseek-ai/dsh-tools',
|
||||||
packageRoot: 'packages/core/tools',
|
packageRoot: 'packages/core/tools',
|
||||||
@@ -141,6 +142,36 @@ describe('typertPlugin', () => {
|
|||||||
.toBe('{"version":3}\n')
|
.toBe('{"version":3}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes stale Remote artifacts from a Host package without Remote output', async () => {
|
||||||
|
const root = await workspace()
|
||||||
|
const output = await packageOutput(root, 'tools', {
|
||||||
|
name: '@deepseek-ai/dsh-tools',
|
||||||
|
exports: { './typert': './lib/typert.host.js' },
|
||||||
|
})
|
||||||
|
const packageLib = join(root, 'packages', 'tools', 'lib')
|
||||||
|
for (const file of [
|
||||||
|
'typert.remote-client.js',
|
||||||
|
'typert.remote-client.d.ts',
|
||||||
|
'typert.remote-client.d.ts.map',
|
||||||
|
]) writeFileSync(join(packageLib, file), 'stale\n')
|
||||||
|
generated.mockReturnValueOnce([{
|
||||||
|
package: '@deepseek-ai/dsh-tools',
|
||||||
|
packageRoot: 'packages/core/tools',
|
||||||
|
face: 'host',
|
||||||
|
exports: [],
|
||||||
|
js: 'export const host = true\n',
|
||||||
|
dts: 'export declare const host: true\n',
|
||||||
|
}])
|
||||||
|
|
||||||
|
typertPlugin().writeBundle({ dir: output })
|
||||||
|
|
||||||
|
for (const file of [
|
||||||
|
'typert.remote-client.js',
|
||||||
|
'typert.remote-client.d.ts',
|
||||||
|
'typert.remote-client.d.ts.map',
|
||||||
|
]) expect(existsSync(join(packageLib, file))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('emits every explicit workspace contributor once from a host-only prepass', async () => {
|
it('emits every explicit workspace contributor once from a host-only prepass', async () => {
|
||||||
const root = await workspace()
|
const root = await workspace()
|
||||||
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })
|
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../support/invariants"
|
"path": "../../support/invariants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../type-meta"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta'
|
||||||
import type {
|
import type {
|
||||||
InvocationDescriptor,
|
InvocationDescriptor,
|
||||||
TypeRTClientContextBinder,
|
TypeRTClientContextBinder,
|
||||||
@@ -600,8 +601,9 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateWireName(subject: string, value: string): void {
|
function validateWireName(subject: string, value: string): void {
|
||||||
validateSegment(subject, value)
|
if (!isTypeRTRemoteSegment(value)) {
|
||||||
if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`)
|
throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSegment(subject: string, value: string): void {
|
function validateSegment(subject: string, value: string): void {
|
||||||
|
|||||||
@@ -247,6 +247,14 @@ describe('TypertRegistry', () => {
|
|||||||
})).toThrow('endpoint "goals/create" is already registered')
|
})).toThrow('endpoint "goals/create" is already registered')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => {
|
||||||
|
const ctx = await makeCtx()
|
||||||
|
expect(() => ctx.typert.remotes.register({
|
||||||
|
package: '@fixture/invalid-endpoint',
|
||||||
|
descriptors: [{ ...invocation(), method }],
|
||||||
|
})).toThrow('RPC endpoint segment characters')
|
||||||
|
})
|
||||||
|
|
||||||
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
|
it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => {
|
||||||
const ctx = await makeCtx()
|
const ctx = await makeCtx()
|
||||||
const descriptor = invocation()
|
const descriptor = invocation()
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
import { Service, type Context } from 'cordis'
|
import { Service, type Context } from 'cordis'
|
||||||
import type { TypeRTContextMap } from './types.ts'
|
import type { TypeRTContextMap } from './types.ts'
|
||||||
|
|
||||||
|
const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test one generated Remote name against the Connection endpoint grammar.
|
||||||
|
* @param value - namespace, method, lookup, or Context segment.
|
||||||
|
* @returns whether the value can cross the shared RPC carrier unchanged.
|
||||||
|
*/
|
||||||
|
export function isTypeRTRemoteSegment(value: string): boolean {
|
||||||
|
return TYPERT_REMOTE_SEGMENT_PATTERN.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
InvocationDescriptor,
|
InvocationDescriptor,
|
||||||
InvocationParameterDescriptor,
|
InvocationParameterDescriptor,
|
||||||
@@ -236,7 +247,7 @@ function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMar
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateName(subject: string, value: string): void {
|
function validateName(subject: string, value: string): void {
|
||||||
if (value.length === 0 || value.includes('/')) {
|
if (!isTypeRTRemoteSegment(value)) {
|
||||||
throw new TypeError(`type-meta: ${subject} must be nonempty and must not contain "/"`)
|
throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ describe('type-meta Remote declarations', () => {
|
|||||||
const method: (this: object) => void = function (this: object): void {}
|
const method: (this: object) => void = function (this: object): void {}
|
||||||
expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing')
|
expect(() => { (Remote as unknown as (value: typeof method) => void)(method) }).toThrow('context is missing')
|
||||||
expect(() => Remote('bad/name')).toThrow('export name')
|
expect(() => Remote('bad/name')).toThrow('export name')
|
||||||
|
expect(() => Remote('bad#name')).toThrow('export name')
|
||||||
|
expect(() => Remote('bad name')).toThrow('export name')
|
||||||
expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key')
|
expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key')
|
||||||
expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name')
|
expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name')
|
||||||
|
|
||||||
@@ -203,6 +205,7 @@ describe('type-meta Remote declarations', () => {
|
|||||||
it('rejects ambiguous binding names', () => {
|
it('rejects ambiguous binding names', () => {
|
||||||
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
|
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
|
||||||
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
|
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
|
||||||
|
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -6172,6 +6172,9 @@ importers:
|
|||||||
|
|
||||||
packages/typert/generator:
|
packages/typert/generator:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@deepseek-ai/dsh-type-meta':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../type-meta
|
||||||
'@jridgewell/gen-mapping':
|
'@jridgewell/gen-mapping':
|
||||||
specifier: ^0.3.13
|
specifier: ^0.3.13
|
||||||
version: 0.3.13
|
version: 0.3.13
|
||||||
|
|||||||
Reference in New Issue
Block a user