fix(typert): address remote gateway review
This commit is contained in:
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}'`
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user