From 9a0a9350c44bf20e57c37daace7fb6746e5d9d00 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:18:35 +0800 Subject: [PATCH] fix(typert): satisfy workspace static gates --- THIRD_PARTY_NOTICES.md | 1 + docs/cordis-catalog/services.md | 2 +- knip.json | 3 +- packages/client/connection/src/client/rpc.ts | 1 - packages/client/connection/src/http-bridge.ts | 6 +- packages/client/connection/src/rpc-host.ts | 26 +- .../connection/tests/client-apply.spec.ts | 43 +++ .../connection/tests/http-bridge.spec.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 68 +++- packages/host/api-gateway/package.json | 4 +- packages/host/api-gateway/src/client/index.ts | 9 +- packages/host/api-gateway/src/index.ts | 10 +- .../host/api-gateway/tests/client.spec.ts | 138 ++++++++ .../host/api-gateway/tests/gateway.spec.ts | 294 ++++++++++++++++++ packages/typert/generator/src/emitter.ts | 27 +- .../typert/generator/src/tsdown-plugin.ts | 35 ++- .../generator/tests/tsdown-plugin.spec.ts | 7 + packages/typert/registry/src/service.ts | 7 + packages/typert/registry/tests/typert.spec.ts | 149 +++++++++ packages/typert/type-meta/package.json | 4 +- .../typert/type-meta/tests/type-meta.spec.ts | 74 +++++ pnpm-lock.yaml | 3 + python/sdk-runtime/package.json | 1 + scripts/check-workspace-constraints.ts | 34 +- scripts/dev-web.spec.ts | 8 +- scripts/dev-web.ts | 23 +- scripts/publication-payload.spec.ts | 31 +- scripts/publication-payload.ts | 36 ++- scripts/publish-npm-baseline.ts | 14 +- 29 files changed, 986 insertions(+), 74 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f70245d49d..e53dd292e4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a9af0bae5..41059aebf4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2585,7 +2585,7 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:324`](../../packages/typert/registry/src/service.ts) ## `ctx.typertGateway` — `TypertGatewayService` diff --git a/knip.json b/knip.json index 7a3922c8fb..32c9e20dbf 100644 --- a/knip.json +++ b/knip.json @@ -200,7 +200,8 @@ "packages/typert/generator": { "entry": [ "tests/**/*.spec.ts", - "tests/fixtures/type-model/**/*.ts" + "tests/fixtures/type-model/**/*.ts", + "tests/fixtures/remote-model/**/*.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 36e16426b2..0c12149d7b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -67,7 +67,6 @@ function resolveBase(): string { function assertTarget(channel: string, endpoint: string): void { const segments = endpoint.split('/') if (!CHANNEL_PATTERN.test(channel) - || segments.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`) diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 319d3e0b0b..88d577bef8 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +interface FetchHandler { + fetch(request: Request): Promise +} + /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; SSE bodies stream out chunk by chunk). @@ -12,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. */ -export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index be9eedca8f..a6fbdb0264 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -7,6 +7,7 @@ import { RpcId, type ClientRequest, type RpcError, + type RpcErrorDetailsMap, type RpcId as RpcIdType, type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -73,10 +74,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): { fetch: typeof fetch } { +): { fetch(request: Request): Promise } { return { - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const request = input instanceof Request ? input : new Request(input, init) + async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) @@ -96,13 +96,7 @@ function rpcFetchHandler( const envelope = clientRequestSchema.safeParse(body) if (!envelope.success) { - const rawId = (body as { rpcId?: unknown } | null)?.rpcId - const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID - return errorResponse(rpcId, { - code: 'bad-request', - message: 'invalid client-request message', - details: { issues: envelope.error.issues }, - }) + return invalidEnvelopeResponse(body, envelope.error.issues) } const message: ClientRequest = envelope.data if (message.method !== endpoint) { @@ -123,11 +117,21 @@ function rpcFetchHandler( } } +function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId + const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID + return errorResponse(rpcId, { + code: 'bad-request', + message: 'invalid client-request message', + details: { issues }, + }) +} + function endpointFromPath(channel: string, pathname: string): string | undefined { if (!pathname.startsWith(`${channel}/`)) return undefined const endpoint = pathname.slice(channel.length + 1) const segments = endpoint.split('/') - if (segments.length === 0 || segments.some(segment => + if (segments.some(segment => segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) { return undefined } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d93844a2b8..3ce8b89ecb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -235,6 +235,49 @@ describe('connection client apply', () => { }) }) + it('validates generic RPC transport failures, correlation, and targets', async () => { + ;(globalThis as Win).location = { + hostname: 'harness.example', search: '', origin: 'https://harness.example', + } + const handle = await mount() + const original = globalThis.fetch + const abort = new AbortController() + globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 })) + try { + await expect(handle.rpc.call('/api2', 'goals/create', {}, abort.signal)) + .rejects.toThrow('HTTP 503') + expect(globalThis.fetch).toHaveBeenCalledWith( + new URL('https://harness.example/api2/goals/create'), + expect.objectContaining({ signal: abort.signal }), + ) + + ;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' } + globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ + type: 'server-response', + rpcId: 'different-rpc', + result: { ok: true, value: null }, + })) + await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow('rpcId mismatch') + const fetch = vi.mocked(globalThis.fetch) + expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api2/goals/create')) + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + } finally { + globalThis.fetch = original + } + + for (const [channel, endpoint] of [ + ['api2', 'goals/create'], + ['/api2/path', 'goals/create'], + ['/api2', ''], + ['/api2', '.'], + ['/api2', '..'], + ['/api2', 'goals//create'], + ['/api2', 'goals/create?unsafe'], + ] as const) { + await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target') + } + }) + it('keeps generic Remote calls unavailable in the client-only fixture', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index 4607f32bae..b06834e523 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => { let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { fetch: async (input) => { - const fetchRequest = input as Request + const fetchRequest = input carrierSignal = fetchRequest.signal resolveStarted() if (!fetchRequest.signal.aborted) { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index af85d4e510..1c42a9dc88 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -47,6 +47,13 @@ function fakePost(headers: Record, url: string, body: unknown): return request } +/** Raw POST for malformed-body and media-type boundary cases. */ +function fakeRawPost(headers: Record, url: string, body: string): IncomingMessage { + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { url, method: 'POST', headers }) + return request +} + /** Response recorder compatible with both the fence's short-circuit and the bridge. */ function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { const state: { status?: number; body?: unknown } = {} @@ -239,7 +246,10 @@ describe('connection node half', () => { const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle - const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), { + const remove = connection.rpc.handle('/api2', async (endpoint) => { + if (endpoint === 'fail') throw new Error('handler broke') + return { ok: true, value: null } + }, { authority: 'trusted-host', }) const route = routes[0]! @@ -248,14 +258,64 @@ describe('connection node half', () => { await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) - const badEnvelope = fakeResponse() + const methodMismatch = fakeResponse() await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, - }), badEnvelope.response) - expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({ + }), methodMismatch.response) + expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', result: { ok: false, error: { code: 'bad-request' } }, }) + + for (const [request, status] of [ + [fakeRequest({ host: 'harness.example' }, '/api2/goals/create'), 404], + [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], + [fakePost({ host: 'harness.example' }, '/api2/goals//create', {}), 404], + [fakeRawPost({ host: 'harness.example' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/api2/goals/create', '{}'), 415], + [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/api2/goals/create', '{'), 400], + ] as const) { + const response = fakeResponse() + await route.handler(request, response.response) + expect(response.state.status).toBe(status) + } + + for (const [body, rpcId] of [ + [{ rpcId: 'retained-id' }, 'retained-id'], + [{ rpcId: 42 }, 'invalid-request'], + [null, 'invalid-request'], + ] as const) { + const response = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', body), response.response) + expect(JSON.parse(String(response.state.body))).toMatchObject({ + rpcId, + result: { ok: false, error: { code: 'bad-request' } }, + }) + } + + const failed = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/api2/fail', { + type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, + }), failed.response) + expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) + + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { + authority: 'loopback', + })).toThrow('invalid or reserved RPC channel') + + const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { + authority: 'loopback', + }) + const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! + const publicResponse = fakeResponse() + await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { + type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, + }), publicResponse.response) + expect(publicResponse.state.status).toBe(403) + await removeLoopback() await remove() await fiber.dispose() }) diff --git a/packages/host/api-gateway/package.json b/packages/host/api-gateway/package.json index 3f3c905f1d..794ae323aa 100644 --- a/packages/host/api-gateway/package.json +++ b/packages/host/api-gateway/package.json @@ -43,9 +43,7 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "dependencies": { diff --git a/packages/host/api-gateway/src/client/index.ts b/packages/host/api-gateway/src/client/index.ts index 57116db2cf..fe8fd9f1b3 100644 --- a/packages/host/api-gateway/src/client/index.ts +++ b/packages/host/api-gateway/src/client/index.ts @@ -90,7 +90,8 @@ class ClientApiService extends Service implements ClientApi { } }, `api-gateway.client.mount(${JSON.stringify(contribution.package)})`) } catch (error) { - disposeRemote().catch(() => {}) + /* v8 ignore next -- rollback disposal only rejects if Cordis teardown itself fails while handling the installation error. */ + Promise.resolve(disposeRemote()).catch(() => {}) throw error } return async () => { @@ -148,6 +149,7 @@ class ClientApiService extends Service implements ClientApi { 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 token.active = false for (const dispose of installed.reverse()) dispose() @@ -173,6 +175,7 @@ class ClientApiService extends Service implements ClientApi { 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 Reflect.deleteProperty(namespace.value, descriptor.method) namespace.tokens.delete(descriptor.method) @@ -203,6 +206,7 @@ class ClientApiService extends Service implements ClientApi { 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 namespace.service.remove(descriptor.method) namespace.tokens.delete(descriptor.method) @@ -289,9 +293,6 @@ class ScopedRemoteNamespace extends Service { }, }) this.methods.add(method) - if (this.methods.size === 1 && this.ownerCtx.get(this.name, false) === undefined) { - this.ownerCtx.set(this.name, this) - } } remove(method: string): void { diff --git a/packages/host/api-gateway/src/index.ts b/packages/host/api-gateway/src/index.ts index ccb76e2d48..c83772261a 100644 --- a/packages/host/api-gateway/src/index.ts +++ b/packages/host/api-gateway/src/index.ts @@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway { private async invokeRpc(endpoint: string, payload: unknown): Promise { try { const segments = endpoint.split('/') - const namespace = segments[0] - const method = segments[1] - if (segments.length !== 2 || namespace === undefined || namespace === '' || method === undefined || method === '') { + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) } + const [namespace, method] = segments as [string, string] if (!isObject(payload) || !isPlainObject(payload) || Reflect.ownKeys(payload).length !== 1 @@ -358,6 +357,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup + /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( 'lookup-unavailable', @@ -492,11 +492,11 @@ function methodParameterNames(service: object, method: string, endpoint: string) const source = Function.prototype.toString.call(implementation) const open = source.indexOf('(') const close = source.indexOf(')', open + 1) + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ if (open < 0 || close < 0) return invalidSignature(endpoint, method) const body = source.slice(open + 1, close).trim() if (body.length === 0) return [] const parts = body.split(',').map(part => part.trim()) - if (parts.at(-1) === '') parts.pop() const names = new Set() for (const part of parts) { if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method) @@ -579,8 +579,8 @@ function assertJsonValue(value: unknown, ancestors: Set): void { if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe') if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe') for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new TypeError('symbol property is not JSON-safe') const descriptor = Object.getOwnPropertyDescriptor(value, key) + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError('non-data property is not JSON-safe') } diff --git a/packages/host/api-gateway/tests/client.spec.ts b/packages/host/api-gateway/tests/client.spec.ts index be0b12ed51..8c0753f3f9 100644 --- a/packages/host/api-gateway/tests/client.spec.ts +++ b/packages/host/api-gateway/tests/client.spec.ts @@ -203,6 +203,144 @@ describe('Client TypeRT API', () => { expect(ctx.typert.remotes.list()).toEqual([]) }) + it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + + expect(() => ctx.api.mount({ + package: '@fixture/direct-duplicates', + descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }], + })).toThrow('repeats direct method') + expect(() => ctx.api.mount({ + package: '@fixture/scoped-duplicates', + descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }], + })).toThrow('repeats scoped method') + + const disposeDirect = ctx.api.mount({ package: '@fixture/direct-live', descriptors: [direct] }) + expect(() => ctx.api.mount({ + package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }], + })).toThrow('direct method goals/create is already mounted') + await disposeDirect() + + const disposeScoped = ctx.api.mount({ package: '@fixture/scoped-live', descriptors: [context] }) + expect(() => ctx.api.mount({ + package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }], + })).toThrow('scoped method goals/rename is already mounted') + expect(() => ctx.api.mount({ + package: '@fixture/service-method-conflict', + descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }], + })).toThrow('conflicts with its namespace service') + await disposeScoped() + + expect(() => ctx.api.mount({ + package: '@fixture/context-property-conflict', + descriptors: [{ ...context, namespace: 'typert' }], + })).toThrow('conflicts with an existing Context property') + + const disposeMultipleScoped = ctx.api.mount({ + package: '@fixture/multiple-scoped', + descriptors: [directDescriptor(), contextDescriptor()], + }) + await disposeMultipleScoped() + }) + + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { + const ctx = await bench(vi.fn()) + const direct = directDescriptor() + const context = contextDescriptor() + expect(() => ctx.api.mount({ + package: '@fixture/weak-parameter', + descriptors: [{ + ...direct, + parameters: direct.parameters.map((parameter, index) => index === 0 + ? { ...parameter, codec: { mode: 'src-json' } } + : parameter), + }], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/weak-context', + descriptors: [{ + ...context, + invocation: { ...context.invocation, codec: { mode: 'src-json' } }, + } as InvocationDescriptor], + })).toThrow('has no strict codec') + expect(() => ctx.api.mount({ + package: '@fixture/malformed-scope', + descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }], + })).toThrow('scope must select its only lookup parameter') + expect(() => ctx.api.mount({ + package: '@fixture/ambiguous-scope', + descriptors: [{ + ...direct, + parameters: [...direct.parameters, { + name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture', + codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema }, + }], + }], + })).toThrow('scope must select its only lookup parameter') + }) + + it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) + const ctx = await bench(call) + const descriptor = directDescriptor() + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] }) + const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise + + await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1') + await expect((ctx as FixtureContext).goals.create({ objective: 'ship' })) + .rejects.toThrow('no Client Context binder') + + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') + ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict' + + ctx.set('connection', undefined) + await expect(ctx.api.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection') + await dispose() + }) + + it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => { + let resolveCall!: (result: Awaited>) => void + const pending = new Promise>>((resolve) => { + resolveCall = resolve + }) + const call = vi.fn().mockReturnValue(pending) + const ctx = await bench(call) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/goals#goals/archive', + method: 'archive', + } + const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [first, second] }) + const invocation = ctx.api.goals.create('agent-1', { objective: 'ship' }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + await dispose() + resolveCall({ ok: true, value: { ref: 'goal-1' } }) + + await expect(invocation).rejects.toThrow('withdrawn during invocation') + expect((ctx.api as unknown as Record).goals).toBeUndefined() + }) + + it('rolls back Remote registration when concrete method installation fails', async () => { + const ctx = await bench(vi.fn()) + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'goals') throw new Error('fixture installation failure') + return defineProperty(target, key, attributes) + }) + try { + expect(() => ctx.api.mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })) + .toThrow('fixture installation failure') + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + } finally { + spy.mockRestore() + } + }) + it('throws RPC failures with the structured error as its cause', async () => { const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) diff --git a/packages/host/api-gateway/tests/gateway.spec.ts b/packages/host/api-gateway/tests/gateway.spec.ts index 8f7c144f5e..0b550e126d 100644 --- a/packages/host/api-gateway/tests/gateway.spec.ts +++ b/packages/host/api-gateway/tests/gateway.spec.ts @@ -229,6 +229,96 @@ class WrongBindingService extends Service { } } +class ExportedMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'exportedMethod', { namespace: 'exported' }) + + constructor(ctx: Context) { + super(ctx, 'exportedMethod') + } + + @Remote('execute') + run(value: string): string { + return value + } +} + +class EmptyMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'emptyMethod', { namespace: 'empty' }) + + constructor(ctx: Context) { + super(ctx, 'emptyMethod') + } + + @Remote + ping(): string { + return 'pong' + } +} + +class CollidingWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'collidingWire', { namespace: 'colliding-wire' }) + + constructor(ctx: Context) { + super(ctx, 'collidingWire') + } + + @Remote + run(agent: FixtureAgent, agentId: string): string { + return `${agent.id}:${agentId}` + } +} + +class ContextWireService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'contextWire', { namespace: 'context-wire' }) + + constructor(ctx: Context) { + super(ctx, 'contextWire') + } + + @RemoteContext('gatewayFixture') + run(agentId: string): string { + return agentId + } +} + +class NoBindingService extends Service { + constructor(ctx: Context) { + super(ctx, 'noBinding') + } + + run(value: string): string { + return value + } +} + +class MissingMethodService extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'missingMethod', { namespace: 'missing-method' }) + + constructor(ctx: Context) { + super(ctx, 'missingMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodBase extends Service { + readonly typertGateway = bindTypeRTGateway(this, 'inheritedMethod', { namespace: 'inherited' }) + + constructor(ctx: Context) { + super(ctx, 'inheritedMethod') + } + + @Remote + run(value: string): string { + return value + } +} + +class InheritedMethodService extends InheritedMethodBase {} + describe('TypertGatewayService', () => { it('invokes a strict direct method with schema decoding and a live lookup', async () => { const { ctx, service } = await setup() @@ -284,6 +374,53 @@ describe('TypertGatewayService', () => { })).resolves.toEqual({ title: 'land', scope: 'agent-src' }) }) + it('derives exported, empty, inherited, and distinct-namespace SRC methods', async () => { + const ctx = await setupGateway() + await ctx.plugin(ExportedMethodService) + await ctx.plugin(EmptyMethodService) + await ctx.plugin(InheritedMethodService) + + await expect(ctx.typertGateway.invoke({ + namespace: 'exported', method: 'execute', args: { value: 'ship' }, + })).resolves.toBe('ship') + await expect(ctx.typertGateway.invoke({ + namespace: 'empty', method: 'ping', args: {}, + })).resolves.toBe('pong') + await expect(ctx.typertGateway.invoke({ + namespace: 'inherited', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'other', method: 'absent', args: {}, + }), 'invocation-unavailable') + }) + + it('rejects SRC wire collisions and unavailable Context providers', async () => { + const colliding = await setupGateway() + await colliding.plugin(CollidingWireService) + registerAgentLookup(colliding, { id: 'agent-1' }) + await expectCode(colliding.typertGateway.invoke({ + namespace: 'colliding-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + + const missing = await setup() + await expectCode(missing.ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-unavailable') + + const contextCollision = await setupGateway() + await contextCollision.plugin(ContextWireService) + contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) + await expectCode(contextCollision.typertGateway.invoke({ + namespace: 'context-wire', + method: 'run', + args: { agentId: 'agent-1' }, + }), 'signature-invalid') + }) + it('re-reads Service and providers on every strict invocation', async () => { const { ctx, serviceFiber } = await setup() const agent = { id: 'agent-1' } @@ -331,6 +468,58 @@ describe('TypertGatewayService', () => { expect(error.cause).toEqual(new Error('provider failed')) }) + it('reports Context provider metadata mismatch and unresolved identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [renameDescriptor()]) + const scoped = ctx.extend() + const mismatch = ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + wire: 'differentAgentId', + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'provider-mismatch') + await mismatch() + + ctx.typert.contexts.registerHost('gatewayFixture', { + ...contextProvider(scoped), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'rename', + args: { agentId: 'agent-1', request: { title: 'land' } }, + }), 'context-not-found') + }) + + it('contains lookup provider failures and missing identities', async () => { + const { ctx } = await setup() + registerStrict(ctx, [createDescriptor()]) + const throwing = ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => { throw new Error('lookup failed') }, + }) + const failure = await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-failed') + expect(failure.cause).toEqual(new Error('lookup failed')) + await throwing() + + ctx.typert.lookups.register('gatewayFixture', { + ...agentLookup({ id: 'agent-1' }), + resolve: () => undefined, + }) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'ship' } }, + }), 'lookup-not-found') + }) + it('never downgrades an observed strict endpoint after definition disposal', async () => { const { ctx } = await setup() const dispose = registerStrict(ctx, [passthroughDescriptor()]) @@ -434,6 +623,11 @@ describe('TypertGatewayService', () => { method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'arguments-invalid') + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: [] as unknown as Record, + }), 'arguments-invalid') expect(service.calls).toEqual([]) }) @@ -492,6 +686,31 @@ describe('TypertGatewayService', () => { }), 'result-invalid') }) + it('accepts dense JSON and rejects decorated arrays and object properties', async () => { + const { ctx } = await setup() + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'passthrough', + args: { value: [1, { nested: true }] }, + })).resolves.toEqual([1, { nested: true }]) + + const sparseWithExtra = Array(1) as unknown[] & { extra?: boolean } + sparseWithExtra.extra = true + const symbolArray = [1] + Object.defineProperty(symbolArray, Symbol('extra'), { value: true }) + const symbolObject = { value: true } + Object.defineProperty(symbolObject, Symbol('extra'), { value: true }) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true, enumerable: false }) + const accessor = {} + Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) + for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'goals', method: 'passthrough', args: { value }, + }), 'input-invalid') + } + }) + it('validates strict provider identity against generated wire metadata', async () => { const { ctx } = await setup() ctx.typert.lookups.register('gatewayFixture', { @@ -525,6 +744,61 @@ describe('TypertGatewayService', () => { }), 'method-unavailable') }) + it('requires a visible binding and supports explicitly provided plain Services', async () => { + const ctx = await setupGateway() + await ctx.plugin(NoBindingService) + registerStrict(ctx, [{ + ...passthroughDescriptor(), + id: '@fixture/gateway#no-binding/run', + service: 'noBinding', + namespace: 'no-binding', + method: 'run', + }]) + await expectCode(ctx.typertGateway.invoke({ + namespace: 'no-binding', method: 'run', args: { value: 'ship' }, + }), 'binding-invalid') + + const plain: { + typertGateway?: ReturnType + run(value: string): string + } = { run: value => value } + plain.typertGateway = bindTypeRTGateway(plain, 'plainRemote', { namespace: 'plain' }) + ctx.provide('plainRemote', plain) + ctx.typert.register({ + package: '@fixture/plain', + face: 'host', + schemas: [], + model: emptyModel, + invocations: [{ + ...passthroughDescriptor(), + id: '@fixture/plain#plain/run', + service: 'plainRemote', + namespace: 'plain', + method: 'run', + }], + }) + await expect(ctx.typertGateway.invoke({ + namespace: 'plain', method: 'run', args: { value: 'land' }, + })).resolves.toBe('land') + }) + + it('reports a SRC marker whose prototype implementation disappeared', async () => { + const ctx = await setupGateway() + await ctx.plugin(MissingMethodService) + const descriptor = Object.getOwnPropertyDescriptor(MissingMethodService.prototype, 'run')! + Object.defineProperty(MissingMethodService.prototype, 'run', { + configurable: true, + value: 42, + }) + try { + await expectCode(ctx.typertGateway.invoke({ + namespace: 'missing-method', method: 'run', args: { value: 'ship' }, + }), 'method-unavailable') + } finally { + Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) + } + }) + it('preserves business exception identity after invocation begins', async () => { const { ctx, service } = await setup() const failure = new Error('business identity') @@ -575,6 +849,26 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) + for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { + const result = await handler(endpoint, { args: {} }, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') + expect(result.error.message).toContain('invalid Remote endpoint') + } + for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { + const result = await handler('goals/create', payload, signal) + expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') + expect(result.error.message).toContain('plain-object args field') + } + + const service = rawGoalService(ctx) + service.businessError = 'non-error failure' as unknown as Error + await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({ + ok: false, + error: { code: 'internal', message: 'non-error failure', details: {} }, + }) + await gatewayFiber.dispose() expect(connection.handler).toBeUndefined() }) diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index 3e79780593..63b1ee7ace 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -399,21 +399,9 @@ export class FaceModelEmitter { scoped: boolean, ): void { const signature = this.remoteSignature(invocation, referenceNames, scoped) - const line = ` ${signature}` - lines.push(line) - const generatedLine = lines.length const keyLength = signature.indexOf(': (') if (keyLength < 0) throw new TypertEmitError(`Remote signature ${invocation.id} has no property delimiter`) - const source = remoteDeclarationSource(packageModel, invocation) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 }, - source, - original: { line: invocation.location.line, column: invocation.location.column - 1 }, - name: invocation.method, - }) - addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + keyLength }, - }) + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, keyLength) } private pushRemoteNamespaceSignature( @@ -424,6 +412,17 @@ export class FaceModelEmitter { referenceNames: ReadonlyMap, ): void { const signature = `${invocation.method}: ${this.remoteFunctionType(invocation, referenceNames, false)}` + this.pushMappedRemoteSignature(lines, sourceMap, packageModel, invocation, signature, invocation.method.length) + } + + private pushMappedRemoteSignature( + lines: string[], + sourceMap: GenMapping, + packageModel: PackageModel, + invocation: InvocationModel, + signature: string, + keyLength: number, + ): void { lines.push(` ${signature}`) const generatedLine = lines.length const source = remoteDeclarationSource(packageModel, invocation) @@ -434,7 +433,7 @@ export class FaceModelEmitter { name: invocation.method, }) addMapping(sourceMap, { - generated: { line: generatedLine, column: 4 + invocation.method.length }, + generated: { line: generatedLine, column: 4 + keyLength }, }) } diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index a5c6ef93e2..10cba60974 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -1,23 +1,27 @@ /** - * Optional tsdown (rolldown) plugin face of the typert generator. When added - * to a workspace tsdown config, it runs after each opted-in package bundle is - * written and re-emits its model-driven face artifact at the package output - * root. Packages without a Typert or Remote export are skipped. + * Optional tsdown (rolldown) plugin face of the typert generator. It lowers + * standard decorators in TypeScript dependencies before bundling, then emits + * model-driven face artifacts at the package output root. Packages without a + * Typert or Remote export are skipped. * @module @deepseek-ai/dsh-typert-generator/tsdown */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' import { WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' import type { TypertFace } from './model.ts' -/** The subset of the rolldown output-plugin contract this plugin uses (structural; avoids a rolldown type dependency). */ +/** The subset of the rolldown plugin contract used here (structural; avoids a rolldown type dependency). */ interface TypertPlugin { name: string + transform: (code: string, id: string) => { code: string; map: string | undefined } | undefined writeBundle: (options: { dir?: string }) => void } +const DECORATOR_SYNTAX = /^\s*@[A-Za-z_$][\w$]*/m + /** Generation scope selected by a tsdown build phase. */ export interface TypertPluginOptions { /** Package mode emits only the package being bundled; workspace mode emits every explicit contributor once. */ @@ -27,15 +31,32 @@ export interface TypertPluginOptions { } /** - * Create the typert generation plugin for the root tsdown config. + * Create the decorator-lowering and typert-generation plugin for the root tsdown config. * @param pluginOptions - package/workspace emission mode and independent program faces. - * @returns a rolldown-compatible plugin that emits local face and Host-for-Client Remote artifacts. + * @returns a rolldown-compatible plugin that lowers source decorators and emits local and Host-for-Client artifacts. */ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlugin { const artifactsByRoot = new Map() const emittedWorkspaces = new Set() return { name: 'dsh-typert-generator', + transform(code, id) { + const file = id.split('?', 1)[0] ?? id + if (!/\.[cm]?tsx?$/.test(file) || !DECORATOR_SYNTAX.test(code)) return + const result = ts.transpileModule(code, { + fileName: file, + compilerOptions: { + target: ts.ScriptTarget.ES2024, + module: ts.ModuleKind.ESNext, + ...(file.endsWith('x') ? { jsx: ts.JsxEmit.ReactJSX } : {}), + sourceMap: true, + }, + }) + return { + code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'), + map: result.sourceMapText, + } + }, writeBundle(bundleOptions) { // options.dir is the package's absolute outDir (/lib); its // nearest package.json owns the bundle even when a custom config writes diff --git a/packages/typert/generator/tests/tsdown-plugin.spec.ts b/packages/typert/generator/tests/tsdown-plugin.spec.ts index 655636aa79..106b8950ff 100644 --- a/packages/typert/generator/tests/tsdown-plugin.spec.ts +++ b/packages/typert/generator/tests/tsdown-plugin.spec.ts @@ -64,6 +64,13 @@ afterEach(() => { }) describe('typertPlugin', () => { + it('lowers standard decorators in TypeScript source dependencies', () => { + const plugin = typertPlugin() + expect(plugin.transform('export const value = 1\n', '/workspace/src/plain.ts')).toBeUndefined() + expect(plugin.transform('@sealed\nexport class Example {}\n', '/workspace/src/example.ts')?.code) + .not.toContain('@sealed') + }) + it('skips outputs that do not identify a Typert contributor', async () => { const plugin = typertPlugin() expect(plugin.name).toBe('dsh-typert-generator') diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 16160a3860..4973732fad 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -149,8 +149,10 @@ class DescriptorStore { for (const descriptor of descriptors) { const endpoint = typertEndpoint(descriptor) const entry = this.entries.get(endpoint) + /* v8 ignore next -- duplicate registration is rejected, so no later owner can replace this entry before its effect disposes. */ if (entry?.owner !== owner) continue this.entries.delete(endpoint) + /* v8 ignore next -- ids and endpoints are committed and withdrawn together under the same unique owner. */ if (this.ids.get(descriptor.id) === entry) this.ids.delete(descriptor.id) removed.push(endpoint) } @@ -200,6 +202,7 @@ class RemoteStore { packages.set(contribution.package, owner) descriptors.commit(owner, contribution.descriptors) yield () => { + /* v8 ignore else -- duplicate package registration is rejected, so this effect remains the package's unique owner. */ if (packages.get(contribution.package) === owner) packages.delete(contribution.package) descriptors.withdraw(owner, contribution.descriptors) } @@ -244,6 +247,7 @@ class LookupStore { providers.set(key, entry) changes.emit({ kind: 'lookup', key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (providers.get(key) !== entry) return providers.delete(key) changes.emit({ kind: 'lookup', key }) @@ -303,6 +307,7 @@ class ContextStore { table.set(key, entry) changes.emit({ kind, key }) yield () => { + /* v8 ignore next -- duplicate registration is rejected, so this effect remains the key's unique owner. */ if (table.get(key) !== entry) return table.delete(key) changes.emit({ kind, key }) @@ -381,8 +386,10 @@ export class TypertRegistry extends Service implements TypeRTService { for (const record of schemaRecords) schemas.set(record.key, record) localStore.commit(owner, invocations) yield () => { + /* v8 ignore else -- duplicate package-face registration is rejected, so this effect remains its unique owner. */ if (packages.get(packageRecord.key) === packageRecord) packages.delete(packageRecord.key) for (const record of schemaRecords) { + /* v8 ignore else -- duplicate schema registration is rejected, so this contribution remains each record's unique owner. */ if (schemas.get(record.key) === record) schemas.delete(record.key) } localStore.withdraw(owner, invocations) diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index a98f99f912..95f8bc871f 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -13,6 +13,7 @@ import type { TypeRTLookup, TypeRTRemoteContribution, } from '@deepseek-ai/dsh-type-meta' +import { apply as applyClientRegistry, inject as clientRegistryInject } from '../src/client/index.ts' declare module '@deepseek-ai/dsh-type-meta' { interface TypeRTLookupMap { @@ -222,6 +223,29 @@ describe('TypertRegistry', () => { expect(changes).toEqual(['local:goals/create', 'local:goals/create']) }) + it('rejects duplicate invocation endpoints and ids atomically', async () => { + const ctx = await makeCtx() + const first = invocation() + ctx.typert.register({ ...toolsContribution(), invocations: [first] }) + + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-endpoint', + descriptors: [invocation('@fixture/remote#first'), invocation('@fixture/remote#second')], + })).toThrow('endpoint "goals/create" is already registered') + expect(() => ctx.typert.remotes.register({ + package: '@fixture/duplicate-id', + descriptors: [ + invocation('@fixture/remote#same'), + { ...invocation('@fixture/remote#same'), method: 'rename' }, + ], + })).toThrow('invocation id "@fixture/remote#same" is already registered') + expect(() => ctx.typert.register({ + ...toolsContribution(), + package: '@fixture/existing-endpoint', + invocations: [{ ...first, id: '@fixture/local#other' }], + })).toThrow('endpoint "goals/create" is already registered') + }) + it('mounts Remote contributions in the calling fiber and withdraws them exactly', async () => { const ctx = await makeCtx() const descriptor = invocation() @@ -314,6 +338,131 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('publishes provider changes, rejects duplicate providers, and disposes subscriptions', async () => { + const ctx = await makeCtx() + const changes: string[] = [] + const disposeLookupSubscription = ctx.typert.lookups.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const disposeContextSubscription = ctx.typert.contexts.subscribe((change) => { + changes.push(`${change.kind}:${change.key}`) + }) + const lookup = { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const host = { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + } + const client = { identity: () => undefined } + const disposeLookup = ctx.typert.lookups.register('fixture', lookup) + const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) + const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client) + + expect(() => ctx.typert.lookups.register('fixture', lookup)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerHost('registryFixture', host)).toThrow('already registered') + expect(() => ctx.typert.contexts.registerClient('registryFixture', client)).toThrow('already registered') + await Promise.all([disposeLookup(), disposeHost(), disposeClient()]) + expect(changes).toEqual([ + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + 'lookup:fixture', + 'host-context:registryFixture', + 'client-context:registryFixture', + ]) + + await Promise.all([disposeLookupSubscription(), disposeContextSubscription()]) + ctx.typert.lookups.register('fixture', lookup) + expect(changes).toHaveLength(6) + }) + + it('validates every invocation and provider boundary', async () => { + const ctx = await makeCtx() + const strict = { + mode: 'strict' as const, + typeSymbol: '@fixture#Value', + schema: z.string(), + } + const strictInvocation: InvocationDescriptor = { + ...invocation('@fixture/remote#strict'), + implementation: 'remoteExportCreate', + parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }], + result: strict, + } + const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] }) + await dispose() + + const malformed: readonly [InvocationDescriptor, string][] = [ + [{ ...invocation(), id: '' }, 'invocation id'], + [{ ...invocation(), namespace: 'bad/name' }, 'namespace'], + [{ ...invocation(), implementation: 'bad/name' }, 'implementation method'], + [{ + ...invocation(), + parameters: [ + ...invocation().parameters, + { name: 'other', wire: 'request', source: 'json', codec: { mode: 'src-json' } }, + ], + }, 'repeats wire field'], + [{ + ...invocation(), + parameters: [{ name: 'agent', wire: 'agentId', source: 'lookup', codec: { mode: 'src-json' } }], + }, 'has no lookup key'], + [{ + ...invocation(), + parameters: [{ + name: 'request', wire: 'request', source: 'json', lookup: 'fixture', codec: { mode: 'src-json' }, + }], + }, 'JSON parameter'], + [{ + ...invocation(), + invocation: { + kind: 'context', context: 'registryFixture', wire: 'request', codec: { mode: 'src-json' }, + }, + }, 'repeats wire field'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '', schema: z.string() }, + }, 'type symbol'], + [{ + ...invocation(), + result: { mode: 'strict', typeSymbol: '@fixture#Broken', schema: {} as z.ZodType }, + }, 'has no parse'], + ] + for (const [index, [descriptor, message]] of malformed.entries()) { + expect(() => ctx.typert.remotes.register({ + package: `@fixture/malformed-${String(index)}`, + descriptors: [descriptor], + })).toThrow(message) + } + + expect(() => ctx.typert.lookups.register('bad#key' as 'fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '', + wireTypeSymbol: '', + resolve: () => undefined, + })).toThrow('lookup key') + expect(() => ctx.typert.lookups.register('fixture', { + parameter: 'agent', + wire: 'agent/id', + hostTypeSymbol: '@fixture#Agent', + wireTypeSymbol: '@fixture#AgentId', + resolve: () => undefined, + })).toThrow('lookup wire field') + }) + + it('installs the registry through the Client entry without importing the Host entry', async () => { + const ctx = new Context() + await ctx.plugin({ inject: clientRegistryInject, apply: applyClientRegistry }) + expect(ctx.typert.list()).toEqual([]) + }) + it('contains change-listener failures and still notifies later listeners', async () => { const ctx = await makeCtx() const warnings: unknown[] = [] diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 2ffcd6c5ed..e2d7689866 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -26,9 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index 1eab5a6ca3..f25c367914 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -107,6 +107,80 @@ describe('type-meta Remote declarations', () => { expect(remoteMethods(first)).toEqual([{ method: 'run', invocation: { kind: 'direct' } }]) }) + it('supports explicit export names without exposing marker storage', () => { + class Service { + run(value: string): string { + return value + } + + scoped(value: string): string { + return value + } + } + const initializers: Array<(this: Service) => void> = [] + Remote('execute')( + Reflect.get(Service.prototype, 'run') as (this: Service, ...args: unknown[]) => unknown, + methodContext('run', initializers), + ) + RemoteContext('metaFixture', 'inspect')( + Reflect.get(Service.prototype, 'scoped') as (this: Service, ...args: unknown[]) => unknown, + methodContext('scoped', initializers), + ) + const service = new Service() + for (const initialize of initializers) initialize.call(service) + + expect(remoteMethods(service)).toEqual([ + { method: 'run', exportName: 'execute', invocation: { kind: 'direct' } }, + { method: 'scoped', exportName: 'inspect', invocation: { kind: 'context', context: 'metaFixture' } }, + ]) + expect(remoteMethods({})).toEqual([]) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(remoteMethods(prototypeLess)).toEqual([]) + }) + + it('rejects malformed decorator calls and targets', () => { + 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(() => RemoteContext('' as 'metaFixture')).toThrow('Context key') + expect(() => RemoteContext('metaFixture', 'bad/name')).toThrow('export name') + + for (const context of [ + { ...methodContext('run', []), private: true }, + { ...methodContext('run', []), static: true }, + { ...methodContext('run', []), name: Symbol('run') }, + ]) { + expect(() => { Remote(method, context) }) + .toThrow('public instance method') + } + }) + + it('rejects prototype-less initialization and conflicting markers', () => { + const method: (this: object) => void = function (this: object): void {} + const direct: Array<(this: object) => void> = [] + Remote(method, methodContext('run', direct)) + const prototypeLess: object = {} + Reflect.setPrototypeOf(prototypeLess, null) + expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + + class Service { + run(): void {} + } + const conflicting: Array<(this: Service) => void> = [] + Remote( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + RemoteContext('metaFixture')( + Reflect.get(Service.prototype, 'run'), + methodContext('run', conflicting), + ) + const service = new Service() + conflicting[0]!.call(service) + expect(() => { conflicting[1]!.call(service) }).toThrow('conflicting invocation markers') + }) + it('rejects ambiguous binding names', () => { expect(() => bindTypeRTGateway({}, '')).toThrow('service key') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e8ad1803..caf1f8a5ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7077,6 +7077,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../packages/typert/type-meta '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/ui/user-approval diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..d8151de3a4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -93,6 +93,7 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..9be97f53e6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' -import { isForbiddenPublicationFile } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' const root = resolve(import.meta.dirname, '..') // vendor/* is single-level; packages// nests one level deeper @@ -122,6 +122,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const typeRTRemoteNavigation = hasTypeRTRemoteNavigation(manifest) return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate @@ -145,9 +146,37 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { // declarations. ...usesEmittedTreeDefaults(manifest) ? ['lib/types/**/*.js'] : [], 'lib/types/**/*.d.ts', + ...hasExportPair(manifest, './typert', './lib/typert.host.d.ts', './lib/typert.host.js') + ? ['lib/typert.host.js', 'lib/typert.host.d.ts'] + : [], + ...hasExportPair(manifest, './client/typert', './lib/typert.client.d.ts', './lib/typert.client.js') + ? ['lib/typert.client.js', 'lib/typert.client.d.ts'] + : [], + ...typeRTRemoteNavigation + ? [ + 'lib/typert.remote-client.js', + 'lib/typert.remote-client.d.ts', + 'lib/typert.remote-client.d.ts.map', + 'src', + ] + : [], ] } +/** Whether one conditional export exactly names the generated runtime and declaration pair. */ +function hasExportPair( + manifest: PackageManifest, + subpath: string, + types: string, + runtime: string, +): boolean { + const entry = manifest.exports?.[subpath] + return typeof entry === 'object' + && entry !== null + && entry.types === types + && entry.default === runtime +} + /** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */ function exportDefault(manifest: PackageManifest, subpath: string): string | undefined { const entry = manifest.exports?.[subpath] @@ -175,8 +204,9 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (manifest.name?.startsWith('@deepseek-ai/')) { + const publicationPolicy = { typeRTRemoteNavigation: hasTypeRTRemoteNavigation(manifest) } for (const file of manifest.files ?? []) { - if (isForbiddenPublicationFile(file)) { + if (isForbiddenPublicationFile(file, publicationPolicy)) { errors.push(`${label}: package.json files must not publish ${JSON.stringify(file)}`) } } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 2edbc652ab..71576caf9e 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -22,13 +22,7 @@ export default defineConfig({ const bundlePath = join(root, 'lib/client.js') await writeFile(sourcePath, 'export const version = "watch-v1"\n') bundles = await watchClientPlugins(root, ['.'], 50) - await expect.poll(async () => { - try { - return (await readFile(bundlePath, 'utf8')).includes('watch-v1') - } catch { - return false - } - }, { timeout: 10_000 }).toBe(true) + expect(await readFile(bundlePath, 'utf8')).toContain('watch-v1') await new Promise(resolve => setTimeout(resolve, 1_000)) await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index aee7146487..294e38002b 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -47,21 +47,40 @@ export function discoverPluginDirs(root = repoRoot): string[] { * @param root - repository or fixture root passed to tsdown. * @param pluginDirs - workspace-relative package directories to watch. * @param pollInterval - optional source-watcher polling interval in milliseconds. - * @returns live bundles whose async disposers stop every watcher. + * @returns live bundles after every watcher has completed its initial build. */ export async function watchClientPlugins( root: string, pluginDirs: readonly string[], pollInterval?: number, ): Promise { - return build({ + let resolveInitialBuilds: (() => void) | undefined + const initialBuilds = new Promise((resolve) => { resolveInitialBuilds = resolve }) + const initialized = new WeakSet() + const readiness: { expectedBuilds?: number; initializedBuilds: number } = { initializedBuilds: 0 } + const bundles = await build({ cwd: root, workspace: [...pluginDirs], watch: true, + hooks: { + 'build:done': ({ options }) => { + if (initialized.has(options)) return + initialized.add(options) + readiness.initializedBuilds += 1 + if ( + readiness.expectedBuilds !== undefined + && readiness.initializedBuilds >= readiness.expectedBuilds + ) resolveInitialBuilds?.() + }, + }, ...pollInterval !== undefined ? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } } : {}, }) + readiness.expectedBuilds = bundles.length + if (readiness.initializedBuilds >= readiness.expectedBuilds) resolveInitialBuilds?.() + await initialBuilds + return bundles } const invokedPath = process.argv[1] diff --git a/scripts/publication-payload.spec.ts b/scripts/publication-payload.spec.ts index 0f403bee7c..03603ef54d 100644 --- a/scripts/publication-payload.spec.ts +++ b/scripts/publication-payload.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isForbiddenPublicationFile, validateTarballPayload } from './publication-payload.ts' +import { + hasTypeRTRemoteNavigation, + isForbiddenPublicationFile, + validateTarballPayload, +} from './publication-payload.ts' function validateFixtureTarball(files: readonly string[]): () => void { return () => { @@ -51,4 +55,29 @@ describe('publication payload policy', () => { 'package/lib/styles/base.css', ])).not.toThrow() }) + + it('allows only the TypeRT declaration map and its navigable source tree when requested', () => { + const policy = { typeRTRemoteNavigation: true } + expect(isForbiddenPublicationFile('src/index.ts', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/typert.remote-client.d.ts.map', policy)).toBe(false) + expect(isForbiddenPublicationFile('lib/types/index.d.ts.map', policy)).toBe(true) + expect(() => { + validateTarballPayload([ + 'package/lib/typert.remote-client.d.ts.map', + 'package/src/index.ts', + ], 'fixture.tgz', policy) + }).not.toThrow() + }) + + it('recognizes only the canonical Host-for-Client export pair', () => { + expect(hasTypeRTRemoteNavigation({ + exports: { + './remote': { + types: './lib/typert.remote-client.d.ts', + default: './lib/typert.remote-client.js', + }, + }, + })).toBe(true) + expect(hasTypeRTRemoteNavigation({ exports: { './remote': './lib/remote.js' } })).toBe(false) + }) }) diff --git a/scripts/publication-payload.ts b/scripts/publication-payload.ts index 9c16067fe7..60f37b4f94 100644 --- a/scripts/publication-payload.ts +++ b/scripts/publication-payload.ts @@ -1,5 +1,22 @@ /** Publication payload policy shared by static manifests and packed tarballs. */ +/** Publication exceptions required for TypeRT declaration-map navigation. */ +export interface PublicationPayloadPolicy { + readonly typeRTRemoteNavigation?: boolean +} + +/** Whether a package manifest exports generated Host-for-Client metadata with source navigation. */ +export function hasTypeRTRemoteNavigation(manifest: unknown): boolean { + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) return false + const exportsField = (manifest as Record).exports + if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false + const remote = (exportsField as Record)['./remote'] + if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false + const entry = remote as Record + return entry.types === './lib/typert.remote-client.d.ts' + && entry.default === './lib/typert.remote-client.js' +} + /** Normalize a package manifest path or npm tarball member to its payload-relative path. */ function payloadPath(file: string): string { const normalized = file.replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '') @@ -7,17 +24,30 @@ function payloadPath(file: string): string { } /** Whether a package payload path exposes source or declaration-map intermediates. */ -export function isForbiddenPublicationFile(file: string): boolean { +export function isForbiddenPublicationFile( + file: string, + policy: PublicationPayloadPolicy = {}, +): boolean { const normalized = payloadPath(file) + if (policy.typeRTRemoteNavigation === true + && (normalized === 'src' + || normalized.startsWith('src/') + || normalized === 'lib/typert.remote-client.d.ts.map')) { + return false + } return normalized === 'src' || normalized.startsWith('src/') || normalized.endsWith('.d.ts.map') } /** Reject source and declaration-map members in a packed npm tarball. */ -export function validateTarballPayload(files: readonly string[], context: string): void { +export function validateTarballPayload( + files: readonly string[], + context: string, + policy: PublicationPayloadPolicy = {}, +): void { for (const file of files) { - if (!isForbiddenPublicationFile(file)) continue + if (!isForbiddenPublicationFile(file, policy)) continue const normalized = payloadPath(file) if (normalized === 'src' || normalized.startsWith('src/')) { throw new Error(`${context} publishes source file ${file}`) diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 20b9eeaea0..4a33f32e1e 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,7 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' -import { validateTarballPayload } from './publication-payload.ts' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline' @@ -320,7 +320,11 @@ class ReleaseBundle { if (expected === undefined || !missingNames.delete(artifact.name)) { throw new Error(`unexpected or duplicate packed package: ${artifact.name}`) } - if (expected.origin === 'harness') validateTarballPayload(artifact.files, tarball) + if (expected.origin === 'harness') { + validateTarballPayload(artifact.files, tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.version !== version) { throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`) } @@ -394,7 +398,11 @@ class ReleaseBundle { throw new Error(`tarball checksum mismatch: ${pkg.tarball}`) } const artifact = inspectTarball(path, runner) - if (pkg.origin === 'harness') validateTarballPayload(artifact.files, pkg.tarball) + if (pkg.origin === 'harness') { + validateTarballPayload(artifact.files, pkg.tarball, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest), + }) + } if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) { throw new Error(`tarball identity mismatch: ${pkg.tarball}`) }