fix(typert): satisfy workspace static gates
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`)}`)
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
interface FetchHandler {
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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<Response> } {
|
||||
return {
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -47,6 +47,13 @@ function fakePost(headers: Record<string, string>, url: string, body: unknown):
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, 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()
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -156,11 +156,10 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
private async invokeRpc(endpoint: string, payload: unknown): Promise<ConnectionRpcResult> {
|
||||
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<string>()
|
||||
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<object>): 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')
|
||||
}
|
||||
|
||||
@@ -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<ConnectionHandle['rpc']['call']>())
|
||||
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<ConnectionHandle['rpc']['call']>())
|
||||
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<ConnectionHandle['rpc']['call']>()
|
||||
.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<unknown>
|
||||
|
||||
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<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().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<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when concrete method installation fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
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<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
}), '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<typeof bindTypeRTGateway>
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -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<SymbolId, string>,
|
||||
): 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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, readonly WorkspaceEmitResult[]>()
|
||||
const emittedWorkspaces = new Set<string>()
|
||||
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 (<package>/lib); its
|
||||
// nearest package.json owns the bundle even when a custom config writes
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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/<group>/<pkg> 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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+21
-2
@@ -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<TsdownBundle[]> {
|
||||
return build({
|
||||
let resolveInitialBuilds: (() => void) | undefined
|
||||
const initialBuilds = new Promise<void>((resolve) => { resolveInitialBuilds = resolve })
|
||||
const initialized = new WeakSet<object>()
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>).exports
|
||||
if (exportsField === null || typeof exportsField !== 'object' || Array.isArray(exportsField)) return false
|
||||
const remote = (exportsField as Record<string, unknown>)['./remote']
|
||||
if (remote === null || typeof remote !== 'object' || Array.isArray(remote)) return false
|
||||
const entry = remote as Record<string, unknown>
|
||||
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}`)
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user