diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 3fc8389079..5503fc3dcf 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -4,7 +4,7 @@ * lookup, invocation, or type exposure. */ -import { Service } from 'cordis' +import { Service, symbols } from 'cordis' import type { Context } from 'cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -84,7 +84,13 @@ class ClientApiService extends Service implements ClientApi { let disposeMethods: () => void | Promise try { disposeMethods = callerCtx.effect(() => { - const installed = contribution.descriptors.map(descriptor => this.install(descriptor)) + const installed: Array<() => void> = [] + try { + for (const descriptor of contribution.descriptors) installed.push(this.install(descriptor)) + } catch (error) { + for (const dispose of installed.reverse()) dispose() + throw error + } return () => { for (const dispose of installed.reverse()) dispose() } @@ -169,21 +175,27 @@ class ClientApiService extends Service implements ClientApi { private installDirect(descriptor: InvocationDescriptor, token: MountToken): () => void { let namespace = this.direct.get(descriptor.namespace) + const fresh = namespace === undefined if (namespace === undefined) { namespace = { value: Object.create(null) as Record, tokens: new Map() } - this.direct.set(descriptor.namespace, namespace) Object.defineProperty(this, descriptor.namespace, { configurable: true, enumerable: true, value: namespace.value, }) } + try { + Object.defineProperty(namespace.value, descriptor.method, { + configurable: true, + enumerable: true, + value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), + }) + } catch (error) { + if (fresh) Reflect.deleteProperty(this, descriptor.namespace) + throw error + } + if (fresh) this.direct.set(descriptor.namespace, namespace) namespace.tokens.set(descriptor.method, token) - Object.defineProperty(namespace.value, descriptor.method, { - configurable: true, - enumerable: true, - value: (...args: unknown[]) => this.invoke(descriptor, undefined, token, this.ownerCtx, args), - }) 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 @@ -242,7 +254,7 @@ class ClientApiService extends Service implements ClientApi { `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`, ) } - const args: Record = {} + const args = Object.create(null) as Record if (projection !== undefined) { const binder = this.ownerCtx.typert.contexts.getClient(projection.context) if (binder === undefined) { @@ -281,9 +293,12 @@ type InvokeRemote = ( args: readonly unknown[], ) => Promise -class ScopedRemoteNamespace extends Service { +class ScopedRemoteNamespace { + private readonly ctx: Context private readonly ownerCtx: Context private readonly methods = new Set() + private provided = false + readonly name: string static assertMethodAvailable(namespace: string, method: string): void { if (SCOPED_NAMESPACE_FIELDS.has(method) || method in ScopedRemoteNamespace.prototype) { @@ -296,8 +311,12 @@ class ScopedRemoteNamespace extends Service { name: string, private readonly invokeRemote: InvokeRemote, ) { - super(ctx, name) + this.ctx = ctx this.ownerCtx = ctx + this.name = name + Object.defineProperty(this, symbols.tracker, { + value: { associate: name, property: 'ctx' }, + }) } assertMethodAvailable(method: string): void { @@ -309,15 +328,28 @@ class ScopedRemoteNamespace extends Service { install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void { this.assertMethodAvailable(descriptor.method) - if (this.methods.size === 0) this.ownerCtx.set(this.name, this) + const activate = this.methods.size === 0 const method = descriptor.method - Object.defineProperty(this, method, { - configurable: true, - enumerable: true, - value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { - return this.invokeRemote(descriptor, projection, token, this.ctx, args) - }, - }) + try { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + value: function (this: ScopedRemoteNamespace, ...args: unknown[]): Promise { + return this.invokeRemote(descriptor, projection, token, this.ctx, args) + }, + }) + if (activate) { + if (this.provided) { + this.ownerCtx.set(this.name, this) + } else { + this.ownerCtx.reflect.provide(this.name, this) + this.provided = true + } + } + } catch (error) { + Reflect.deleteProperty(this, method) + throw error + } this.methods.add(method) } @@ -328,7 +360,7 @@ class ScopedRemoteNamespace extends Service { } } -const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx']) +const SCOPED_NAMESPACE_FIELDS = new Set(['ctx', 'invokeRemote', 'methods', 'name', 'ownerCtx', 'provided']) function endpointOf(descriptor: Pick): string { return `${descriptor.namespace}/${descriptor.method}` diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index 5c2c427605..c1f94f2e44 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -321,6 +321,34 @@ describe('Client TypeRT API', () => { await disposeScoped() }) + it('rolls back earlier descriptors when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture later-descriptor failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/failing-batch', descriptors: [first, second] })) + .toThrow('fixture later-descriptor failure') + } finally { + spy.mockRestore() + } + + expect((ctx.api as unknown as Record).goals).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/retry-batch', descriptors: [first, second] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + expect((ctx.api.goals as unknown as Record).archive).toBeTypeOf('function') + await retry() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -409,6 +437,33 @@ describe('Client TypeRT API', () => { expect((ctx.api as unknown as Record).goals).toBeUndefined() }) + it('preserves a __proto__ wire parameter as an own named argument', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const { scope: _scope, ...base } = directDescriptor() + const descriptor: InvocationDescriptor = { + ...base, + id: '@fixture/goals#goals/prototype', + method: 'prototype', + parameters: [{ + name: 'value', + wire: '__proto__', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() }, + }], + } + const dispose = ctx.api.mount({ package: '@fixture/prototype', descriptors: [descriptor] }) + + const method = (ctx.api.goals as unknown as Record Promise>).prototype + await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' }) + const payload = call.mock.calls[0]?.[2] as { readonly args: Record } + expect(Object.getPrototypeOf(payload.args)).toBeNull() + expect(Object.hasOwn(payload.args, '__proto__')).toBe(true) + expect(payload.args.__proto__).toBe('wire-value') + await dispose() + }) + it('rolls back Remote registration when concrete method installation fails', async () => { const ctx = await bench(vi.fn()) const defineProperty = Object.defineProperty @@ -423,6 +478,31 @@ describe('Client TypeRT API', () => { } finally { spy.mockRestore() } + + const retry = ctx.api.mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] }) + expect(ctx.api.goals.create).toBeTypeOf('function') + await retry() + }) + + it('withdraws a fresh scoped Service when its first method fails to install', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'rename') throw new Error('fixture scoped installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] })) + .toThrow('fixture scoped installation failure') + } finally { + spy.mockRestore() + } + + expect(ctx.get('goals')).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + const retry = ctx.api.mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] }) + expect((ctx.get('goals') as unknown as Record).rename).toBeTypeOf('function') + await retry() }) it('throws RPC failures with the structured error as its cause', async () => { diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 16c30e8bc5..bc5024a7d8 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -2810,7 +2810,9 @@ function stringLiteralValue(node: ts.Node | undefined): string | undefined { } function isRemoteSegment(value: string): boolean { - return /^[A-Za-z0-9_$.-]+$/.test(value) + // Generation bootstraps workspace artifacts before dsh-type-meta is built, + // so this extraction-only copy must mirror isTypeRTRemoteSegment(). + return value !== '.' && value !== '..' && /^[A-Za-z0-9_$.-]+$/.test(value) } function expressionName(node: ts.Expression): string | undefined { diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 6327872166..4a303c4bd4 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -90,6 +90,7 @@ export class WorkspaceTypertGenerator { throw new TypertAnalysisError(`typert(${artifact.face}): ${artifact.package} package files must include ${file}`) } } + if (artifact.face !== 'host') return const remoteExpected = { types: './lib/typert.remote-client.d.ts', default: './lib/typert.remote-client.js', diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 4f4f3ea7cb..27bdac2fac 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -274,7 +274,7 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) - it.each(['create#v2', 'create goal'])('rejects untransportable Remote alias %s', (alias) => { + 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(', @@ -301,6 +301,37 @@ export interface RemainingSchema { .toThrow('publishes Remote artifacts but has no Remote methods') }) + it('validates Remote artifacts only on the host face of a dual-face package', () => { + const root = copyFixture() + const manifestPath = join(root, 'packages/remote/package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dshClient?: object + exports: Record + files: string[] + } + manifest.dshClient = {} + manifest.exports['./client'] = './src/client.ts' + manifest.exports['./client/typert'] = { + types: './lib/typert.client.d.ts', + default: './lib/typert.client.js', + } + manifest.files.push('lib/typert.client.js', 'lib/typert.client.d.ts') + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + writeFileSync(join(root, 'tsconfig.client.json'), `${JSON.stringify({ + extends: './tsconfig.base.json', + files: [], + references: [{ path: './packages/remote' }], + }, null, 2)}\n`) + writeFileSync(join(root, 'packages/remote/src/client.ts'), `/** @typert schema */ +export interface ClientMarker { + readonly ready: boolean +} +`) + + expect(new WorkspaceTypertGenerator(root).generate().map(artifact => artifact.face)) + .toEqual(['host', 'client']) + }) + it.each([ { name: 'missing binding', diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 2f85138edd..3631253342 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -600,7 +600,7 @@ function validateCodec(codec: InvocationDescriptor['result'], subject: string): } function validateWireName(subject: string, value: string): void { - if (!/^[A-Za-z0-9_$.-]+$/.test(value)) { + if (value === '.' || value === '..' || !/^[A-Za-z0-9_$.-]+$/.test(value)) { throw new Error(`typert: invalid ${subject} "${value}" — must contain only RPC endpoint segment characters`) } } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 6661cbeeb4..29654babf7 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -247,7 +247,7 @@ describe('TypertRegistry', () => { })).toThrow('endpoint "goals/create" is already registered') }) - it.each(['create#v2', 'create goal'])('rejects untransportable invocation method %s', async (method) => { + 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', diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 67a4169f96..3d782dbb77 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -15,7 +15,7 @@ const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ * @returns whether the value can cross the shared RPC carrier unchanged. */ export function isTypeRTRemoteSegment(value: string): boolean { - return TYPERT_REMOTE_SEGMENT_PATTERN.test(value) + return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } export type { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 757488024d..b84b76300c 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -164,6 +164,8 @@ describe('type-meta Remote declarations', () => { expect(() => Remote('bad/name')).toThrow('export name') expect(() => Remote('bad#name')).toThrow('export name') expect(() => Remote('bad name')).toThrow('export name') + expect(() => Remote('.')).toThrow('export name') + expect(() => Remote('..')).toThrow('export name') expect(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name')