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

@@ -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')
})
})