fix(typert): address remote gateway review

This commit is contained in:
imccyu
2026-08-07 11:05:55 +08:00
parent da1ebd2b68
commit 2e1f9a5cea
19 changed files with 275 additions and 58 deletions

View File

@@ -2,5 +2,5 @@
# 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 .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.zh.md: 38e3ca286dad98665269697f49fba731346e665e
2026-08-02-typert-remote-method-calls.md: 13b407d580c6042a71234e55cdb61225910f0e48
2026-08-02-typert-remote-method-calls.zh.md: 434cf4765d2206f3c6f99b67c156b9508d70f313

View File

@@ -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.
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.

View File

@@ -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 对象的内部结构。
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。

View File

@@ -134,8 +134,11 @@ class ClientApiService extends Service implements ClientApi {
const record = this.scoped.get(namespace)
if (record !== undefined) {
for (const method of methods) record.service.assertMethodAvailable(method)
} else if (this.ownerCtx.reflect.props[namespace] !== undefined) {
throw new Error(`client api: scoped namespace ${JSON.stringify(namespace)} conflicts with an existing Context property`)
} else {
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 {
const token: MountToken = { active: true, abort: new AbortController() }
const installed: (() => void)[] = []
if (descriptor.invocation.kind === 'direct') {
installed.push(this.installDirect(descriptor, token))
try {
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 () => {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!token.active) return
@@ -192,19 +202,19 @@ class ClientApiService extends Service implements ClientApi {
): () => void {
let namespace = this.scoped.get(descriptor.namespace)
if (namespace === undefined) {
namespace = {
service: new ScopedRemoteNamespace(
this.ownerCtx,
descriptor.namespace,
(current, currentProjection, currentToken, caller, args) =>
this.invoke(current, currentProjection, currentToken, caller, args),
),
tokens: new Map(),
}
const service = new ScopedRemoteNamespace(
this.ownerCtx,
descriptor.namespace,
(current, currentProjection, currentToken, caller, args) =>
this.invoke(current, currentProjection, currentToken, caller, args),
)
service.install(descriptor, projection, token)
namespace = { service, tokens: new Map() }
this.scoped.set(descriptor.namespace, namespace)
} else {
namespace.service.install(descriptor, projection, token)
}
namespace.tokens.set(descriptor.method, token)
namespace.service.install(descriptor, projection, token)
return () => {
/* 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
@@ -275,6 +285,12 @@ class ScopedRemoteNamespace extends Service {
private readonly ownerCtx: Context
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(
ctx: Context,
name: string,
@@ -285,6 +301,7 @@ class ScopedRemoteNamespace extends Service {
}
assertMethodAvailable(method: string): void {
ScopedRemoteNamespace.assertMethodAvailable(this.name, method)
if (method in this) {
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 {
return `${descriptor.namespace}/${descriptor.method}`
}

View File

@@ -279,6 +279,24 @@ describe('Client TypeRT API', () => {
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 () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor()

View File

@@ -30,6 +30,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@jridgewell/gen-mapping": "^0.3.13",
"typescript": "^6.0.3"
},

View File

@@ -8,6 +8,7 @@
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta'
import type {
CrossFaceLink,
DocumentationModel,
@@ -1171,8 +1172,8 @@ class FaceAnalyzer {
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 "/"')
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 contain only RPC endpoint segment characters')
return { service, namespace, site }
}
@@ -1196,7 +1197,7 @@ class FaceAnalyzer {
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 "/"')
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters')
}
marker = { kind: 'direct', exportName }
} else if (ts.isCallExpression(expression)
@@ -1206,12 +1207,12 @@ class FaceAnalyzer {
}
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 "/"')
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 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 "/"')
this.fail(exportArgument, 'RemoteContext() name must be a string literal containing only RPC endpoint segment characters')
}
marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } }
} else {
@@ -1251,7 +1252,7 @@ class FaceAnalyzer {
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 (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTLookupMap key must contain only RPC endpoint segment characters')
if (!ts.isTypeReferenceNode(declaration.type)
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTLookup')
|| declaration.type.typeArguments?.length !== 2) {
@@ -1287,7 +1288,7 @@ class FaceAnalyzer {
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 (!isRemoteSegment(key)) this.fail(declaration.name, 'TypeRTContextMap key must contain only RPC endpoint segment characters')
if (!ts.isTypeReferenceNode(declaration.type)
|| !this.isTypeMetaSymbol(declaration.type.typeName, 'TypeRTContext')
|| declaration.type.typeArguments?.length !== 1) {
@@ -1331,16 +1332,6 @@ class FaceAnalyzer {
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))) {
@@ -1355,13 +1346,23 @@ class FaceAnalyzer {
&& this.registrationForFile(declaration.getSourceFile().fileName) !== undefined) {
const imported = this.publicRemoteType(resolved, node)
imports.set(imported.symbol, imported)
return
}
}
}
ts.forEachChild(node, visit)
}
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 {
type,
codecType,
@@ -2810,7 +2811,7 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined {
}
function isRemoteSegment(value: string): boolean {
return value.length > 0 && !value.includes('/')
return isTypeRTRemoteSegment(value)
}
function expressionName(node: ts.Expression): string | undefined {

View File

@@ -414,8 +414,9 @@ export class FaceModelEmitter {
invocation: InvocationModel,
referenceNames: ReadonlyMap<SymbolId, string>,
): void {
const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length)
const key = renderRemotePropertyName(invocation.method)
const signature = `${key}: ${this.remoteFunctionType(invocation, referenceNames, false)}`
this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, key.length)
}
private pushMappedRemoteSignature(
@@ -914,6 +915,10 @@ function safeIdentifier(name: string): string {
return `_${normalized}`
}
function renderRemotePropertyName(name: string): string {
return /^[$A-Z_a-z][$\w]*$/u.test(name) ? name : quote(name)
}
function quote(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'`
}

View File

@@ -6,7 +6,7 @@
* @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 ts from 'typescript'
import { WorkspaceTypertGenerator } from './workspace.ts'
@@ -103,15 +103,24 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu
function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void {
const output = join(packageDir, 'lib')
mkdirSync(output, { recursive: true })
let emittedRemote = false
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) {
emittedRemote = true
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)
}
}
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 } {

View File

@@ -90,7 +90,6 @@ 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',
@@ -98,16 +97,25 @@ export class WorkspaceTypertGenerator {
const remoteActual = manifest.exports !== null && typeof manifest.exports === 'object'
? (manifest.exports as Record<string, unknown>)['./remote']
: 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)) {
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',
]) {
for (const file of remoteFiles) {
if (!files.includes(file)) {
throw new TypertAnalysisError(`typert(host): ${artifact.package} package files must include ${file}`)
}

View File

@@ -50,7 +50,13 @@ declare module '@deepseek-ai/dsh-type-meta' {
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
): 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>(
method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,

View File

@@ -216,6 +216,91 @@ export type GenericResult = {
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([
{
name: 'missing binding',
@@ -429,9 +514,9 @@ function remotePackage(root: string): {
return packageModel
}
function copyFixture(): string {
function copyFixture(sourceRoot = fixtureRoot): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-typert-remote-model-'))
cpSync(fixtureRoot, root, { recursive: true })
cpSync(sourceRoot, root, { recursive: true })
temporaryRoots.push(root)
return root
}
@@ -444,10 +529,14 @@ function editFile(root: string, relativePath: string, edit: (source: string) =>
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 (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 declarationMapPath = `${declarationPath}.map`
const consumerPath = join(consumerRoot, 'consumer.ts')

View File

@@ -3,8 +3,9 @@ import { mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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',
packageRoot: 'packages/core/tools',
@@ -141,6 +142,36 @@ describe('typertPlugin', () => {
.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 () => {
const root = await workspace()
const trigger = await packageOutput(root, 'generator', { name: '@deepseek-ai/dsh-typert-generator' })

View File

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

View File

@@ -7,6 +7,7 @@
import { Context, Service } from 'cordis'
import { z } from 'zod'
import { isTypeRTRemoteSegment } from '@deepseek-ai/dsh-type-meta'
import type {
InvocationDescriptor,
TypeRTClientContextBinder,
@@ -600,8 +601,9 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string):
}
function validateWireName(subject: string, value: string): void {
validateSegment(subject, value)
if (value.includes('/')) throw new Error(`typert: invalid ${subject} "${value}" — must not contain "/"`)
if (!isTypeRTRemoteSegment(value)) {
throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`)
}
}
function validateSegment(subject: string, value: string): void {

View File

@@ -247,6 +247,14 @@ describe('TypertRegistry', () => {
})).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 () => {
const ctx = await makeCtx()
const descriptor = invocation()

View File

@@ -7,6 +7,17 @@
import { Service, type Context } from 'cordis'
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 {
InvocationDescriptor,
InvocationParameterDescriptor,
@@ -236,7 +247,7 @@ function sameInvocation(left: RemoteInvocationMarker, right: RemoteInvocationMar
}
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 "/"`)
if (!isTypeRTRemoteSegment(value)) {
throw new TypeError(`type-meta: ${subject} must contain only RPC endpoint segment characters`)
}
}

View File

@@ -162,6 +162,8 @@ describe('type-meta Remote declarations', () => {
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('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('metaFixture', 'bad/name')).toThrow('export name')
@@ -203,6 +205,7 @@ describe('type-meta Remote declarations', () => {
it('rejects ambiguous binding names', () => {
expect(() => bindTypeRTGateway({}, '')).toThrow('service key')
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
})
})

3
pnpm-lock.yaml generated
View File

@@ -6172,6 +6172,9 @@ importers:
packages/typert/generator:
dependencies:
'@deepseek-ai/dsh-type-meta':
specifier: workspace:^
version: link:../type-meta
'@jridgewell/gen-mapping':
specifier: ^0.3.13
version: 0.3.13