refactor(typert): bind remote services through base class
This commit is contained in:
@@ -142,7 +142,7 @@ interface StaticContextDeclaration {
|
||||
interface GatewayBinding {
|
||||
readonly service: string
|
||||
readonly namespace: string
|
||||
readonly site: ts.PropertyDeclaration
|
||||
readonly site: ts.Node
|
||||
}
|
||||
|
||||
type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.ImportTypeNode
|
||||
@@ -927,7 +927,10 @@ class FaceAnalyzer {
|
||||
if (first === undefined) continue
|
||||
const binding = this.gatewayBinding(statement)
|
||||
if (binding === undefined) {
|
||||
this.fail(first.method, 'Remote methods require readonly typertGateway = bindTypeRTGateway(this, serviceKey)')
|
||||
this.fail(
|
||||
first.method,
|
||||
'Remote methods require GatewayService or readonly typertGateway = bindTypeRTGateway(this, serviceKey)',
|
||||
)
|
||||
}
|
||||
for (const { method, invocation } of marked) {
|
||||
result.push(this.invocationModel(registration, binding, method, invocation))
|
||||
@@ -1089,6 +1092,15 @@ class FaceAnalyzer {
|
||||
}
|
||||
|
||||
private gatewayBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
|
||||
const field = this.gatewayFieldBinding(declaration)
|
||||
const base = this.gatewayServiceBinding(declaration)
|
||||
if (field !== undefined && base !== undefined) {
|
||||
this.fail(field.site, 'GatewayService subclasses must not declare a second typertGateway binding')
|
||||
}
|
||||
return field ?? base
|
||||
}
|
||||
|
||||
private gatewayFieldBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
|
||||
const candidates = declaration.members.filter((member): member is ts.PropertyDeclaration =>
|
||||
ts.isPropertyDeclaration(member) && memberName(member.name) === 'typertGateway')
|
||||
const [property, duplicate] = candidates
|
||||
@@ -1111,10 +1123,38 @@ class FaceAnalyzer {
|
||||
if (call.arguments[0]?.kind !== ts.SyntaxKind.ThisKeyword) {
|
||||
this.fail(call.arguments[0] ?? call, 'bindTypeRTGateway() first argument must be this')
|
||||
}
|
||||
return this.gatewayBindingArguments(call, property)
|
||||
}
|
||||
|
||||
private gatewayServiceBinding(declaration: ts.ClassDeclaration): GatewayBinding | undefined {
|
||||
const heritage = (declaration.heritageClauses ?? [])
|
||||
.filter(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)
|
||||
.flatMap(clause => [...clause.types])
|
||||
.find(type => this.isTypeMetaSymbol(type.expression, 'GatewayService'))
|
||||
if (heritage === undefined) return undefined
|
||||
|
||||
const constructor = declaration.members.find(ts.isConstructorDeclaration)
|
||||
if (constructor?.body === undefined) {
|
||||
this.fail(heritage, 'GatewayService subclasses must declare a constructor with super(ctx, serviceKey)')
|
||||
}
|
||||
const call = constructor.body.statements.flatMap((statement) => {
|
||||
if (!ts.isExpressionStatement(statement) || !ts.isCallExpression(statement.expression)) return []
|
||||
return statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword ? [statement.expression] : []
|
||||
})[0]
|
||||
if (call === undefined) {
|
||||
this.fail(constructor, 'GatewayService constructor must call super(ctx, serviceKey) directly')
|
||||
}
|
||||
if (call.arguments.length < 2 || call.arguments.length > 3) {
|
||||
this.fail(call, 'GatewayService super() requires context, service key, and an optional options object')
|
||||
}
|
||||
return this.gatewayBindingArguments(call, heritage)
|
||||
}
|
||||
|
||||
private gatewayBindingArguments(call: ts.CallExpression, site: ts.Node): GatewayBinding {
|
||||
const serviceArgument = call.arguments[1]
|
||||
if (serviceArgument === undefined) this.fail(call, 'bindTypeRTGateway() service key must be a string literal')
|
||||
if (serviceArgument === undefined) this.fail(call, 'Gateway service key must be a string literal')
|
||||
const service = stringLiteralValue(serviceArgument)
|
||||
if (service === undefined) this.fail(serviceArgument, 'bindTypeRTGateway() service key must be a string literal')
|
||||
if (service === undefined) this.fail(serviceArgument, 'Gateway service key must be a string literal')
|
||||
let namespace = service
|
||||
const options = call.arguments[2]
|
||||
if (options !== undefined) {
|
||||
@@ -1133,7 +1173,7 @@ class FaceAnalyzer {
|
||||
}
|
||||
if (!isRemoteSegment(service)) this.fail(serviceArgument, 'Gateway service key must be nonempty and must not contain "/"')
|
||||
if (!isRemoteSegment(namespace)) this.fail(options ?? call, 'Gateway namespace must be nonempty and must not contain "/"')
|
||||
return { service, namespace, site: property }
|
||||
return { service, namespace, site }
|
||||
}
|
||||
|
||||
private remoteMarker(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Remote, RemoteContext, bindTypeRTGateway } from '@deepseek-ai/dsh-type-meta'
|
||||
import { GatewayService, Remote, RemoteContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { Agent } from '@fixture/domain'
|
||||
import type {
|
||||
CreateGoalRequest,
|
||||
@@ -8,8 +8,10 @@ import type {
|
||||
} from './types.ts'
|
||||
|
||||
/** Remote-only business Service with no Cordis declaration merge. */
|
||||
export class GoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
export class GoalService extends GatewayService {
|
||||
constructor() {
|
||||
super(undefined, 'goals')
|
||||
}
|
||||
|
||||
@Remote
|
||||
async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult> {
|
||||
|
||||
@@ -26,6 +26,19 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
readonly descriptors: readonly unknown[]
|
||||
}
|
||||
|
||||
export abstract class GatewayService {
|
||||
readonly typertGateway: {
|
||||
readonly service: GatewayService
|
||||
readonly serviceKey: string
|
||||
readonly namespace: string
|
||||
}
|
||||
protected constructor(
|
||||
ctx: unknown,
|
||||
serviceKey: string,
|
||||
options?: { readonly namespace?: string },
|
||||
)
|
||||
}
|
||||
|
||||
export function bindTypeRTGateway<Service extends object>(
|
||||
service: Service,
|
||||
serviceKey: string,
|
||||
|
||||
@@ -219,8 +219,56 @@ export type GenericResult = {
|
||||
it.each([
|
||||
{
|
||||
name: 'missing binding',
|
||||
edit: (source: string) => source.replace(" readonly typertGateway = bindTypeRTGateway(this, 'goals')\n\n", ''),
|
||||
message: 'Remote methods require readonly typertGateway',
|
||||
edit: (source: string) => source.replace(
|
||||
"export class GoalService extends GatewayService {\n constructor() {\n super(undefined, 'goals')\n }",
|
||||
'export class GoalService {',
|
||||
),
|
||||
message: 'Remote methods require GatewayService',
|
||||
},
|
||||
{
|
||||
name: 'dynamic GatewayService key',
|
||||
edit: (source: string) => source.replace(
|
||||
" constructor() {\n super(undefined, 'goals')\n }",
|
||||
' constructor(serviceKey: string) {\n super(undefined, serviceKey)\n }',
|
||||
),
|
||||
message: 'Gateway service key must be a string literal',
|
||||
},
|
||||
{
|
||||
name: 'GatewayService without a constructor',
|
||||
edit: (source: string) => source.replace(
|
||||
" constructor() {\n super(undefined, 'goals')\n }\n\n",
|
||||
'',
|
||||
),
|
||||
message: 'GatewayService subclasses must declare a constructor',
|
||||
},
|
||||
{
|
||||
name: 'GatewayService without a direct super call',
|
||||
edit: (source: string) => source.replace(
|
||||
" super(undefined, 'goals')",
|
||||
' void undefined',
|
||||
),
|
||||
message: 'GatewayService constructor must call super',
|
||||
},
|
||||
{
|
||||
name: 'GatewayService super call without a service key',
|
||||
edit: (source: string) => source.replace(
|
||||
" super(undefined, 'goals')",
|
||||
' super(undefined)',
|
||||
),
|
||||
message: 'GatewayService super\\(\\) requires context, service key',
|
||||
},
|
||||
{
|
||||
name: 'duplicate GatewayService field binding',
|
||||
edit: (source: string) => source
|
||||
.replace(
|
||||
'import { GatewayService, Remote, RemoteContext }',
|
||||
'import { GatewayService, Remote, RemoteContext, bindTypeRTGateway }',
|
||||
)
|
||||
.replace(
|
||||
'export class GoalService extends GatewayService {',
|
||||
"export class GoalService extends GatewayService {\n readonly typertGateway = bindTypeRTGateway(this, 'goals')",
|
||||
),
|
||||
message: 'GatewayService subclasses must not declare a second typertGateway binding',
|
||||
},
|
||||
{
|
||||
name: 'private method',
|
||||
@@ -351,8 +399,10 @@ export type GenericResult = {
|
||||
it('rejects duplicate endpoints across Remote services', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => `${source}
|
||||
export class DuplicateGoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'duplicate', { namespace: 'goals' })
|
||||
export class DuplicateGoalService extends GatewayService {
|
||||
constructor() {
|
||||
super(undefined, 'duplicate', { namespace: 'goals' })
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(request: CreateGoalRequest): CreateGoalResult {
|
||||
@@ -521,7 +571,7 @@ ctx.api.goals.create('agent-1', { title: 'must not compile' })
|
||||
if (config.error !== undefined) throw new Error(formatDiagnostics([config.error]))
|
||||
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, consumerRoot, undefined, configPath)
|
||||
const diagnostics = ts.getPreEmitDiagnostics(ts.createProgram(parsed.fileNames, parsed.options))
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics, formatDiagnostics(diagnostics)).toHaveLength(1)
|
||||
expect(diagnostics[0]?.code).toBe(2339)
|
||||
expect(ts.flattenDiagnosticMessageText(diagnostics[0]?.messageText ?? '', '\n')).toContain("Property 'goals' does not exist")
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
|
||||
README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae
|
||||
README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3
|
||||
README.md: 245df305efcf711486b2d3f32e40a8b415f2682e
|
||||
README.zh.md: 592aa5d027a52a7a277a90ba5d51f19101f055f6
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns Remote decorators, the explicit Service binding, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or provide a Cordis service.
|
||||
Compiler-independent declarations shared by business packages, generated TypeRT artifacts, the Host Gateway, and Client API. This package owns the Remote Service base, decorators, explicit binding fallback, merge-extensible protocol maps, invocation descriptors, codecs, and provider contracts; it does not run TypeScript analysis or register a concrete Cordis service.
|
||||
|
||||
## Remote declarations
|
||||
|
||||
- `@Remote` marks a public instance method for direct invocation on its registered Cordis Service.
|
||||
- `@RemoteContext(key)` marks a method whose receiver is selected from a merge-declared scoped Context kind.
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
|
||||
- `GatewayService` binds the Cordis key passed to `super(ctx, serviceKey, options?)` to the same default wire namespace.
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` provides the same visible, frozen binding for a Service that cannot inherit from `GatewayService`.
|
||||
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
|
||||
|
||||
A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type.
|
||||
|
||||
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
|
||||
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. A `GatewayService` exposes the same public readonly `typertGateway` binding that the explicit helper returns.
|
||||
|
||||
## TypeRT protocol
|
||||
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote 装饰器、显式服务绑定、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不提供 Cordis 服务。
|
||||
该包提供不依赖编译器的声明,由业务包、生成的 TypeRT 产物、Host Gateway 和 Client API 共享。它负责 Remote Service 基类、装饰器、显式 binding 回退、可通过声明合并扩展的协议映射、调用描述符、编解码器和提供方契约;它不执行 TypeScript 分析,也不注册具体 Cordis 服务。
|
||||
|
||||
## Remote 声明
|
||||
|
||||
- `@Remote` 将公开实例方法标记为可在其注册的 Cordis 服务上直接调用。
|
||||
- `@RemoteContext(key)` 标记接收者选自合并声明的作用域 Context 类型的方法。
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
|
||||
- `GatewayService` 将 `super(ctx, serviceKey, options?)` 接收的 Cordis key 同时绑定为默认 wire namespace。
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` 为无法继承 `GatewayService` 的 Service 提供同样可见且冻结的绑定。
|
||||
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
|
||||
|
||||
Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。
|
||||
|
||||
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
|
||||
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。`GatewayService` 会暴露与显式 helper 相同的 public readonly `typertGateway` 绑定。
|
||||
|
||||
## TypeRT 协议
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-type-meta
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { TypeRTContextMap } from './types.ts'
|
||||
|
||||
export type {
|
||||
@@ -104,6 +105,23 @@ export function bindTypeRTGateway<Service extends object>(
|
||||
return Object.freeze({ service, serviceKey, namespace })
|
||||
}
|
||||
|
||||
/** Cordis Service base that exposes its registered name through TypeRT Gateway. */
|
||||
export abstract class GatewayService<out T = never> extends Service<T> {
|
||||
/** Visible binding consumed by the Gateway's source-mode discovery. */
|
||||
readonly typertGateway: TypeRTGatewayBinding<this>
|
||||
|
||||
/**
|
||||
* Register the Service and bind the same key to TypeRT Gateway.
|
||||
* @param ctx - owning Cordis Context.
|
||||
* @param serviceKey - exact Cordis service key and default wire namespace.
|
||||
* @param options - optional distinct wire namespace.
|
||||
*/
|
||||
protected constructor(ctx: Context, serviceKey: string, options: TypeRTGatewayBindingOptions = {}) {
|
||||
super(ctx, serviceKey)
|
||||
this.typertGateway = bindTypeRTGateway(this, this.name, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one public instance method as a direct Remote invocation.
|
||||
* @param _method - decorated method; retained only by the class itself.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
GatewayService,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
class Goals extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
@@ -19,7 +22,7 @@ class Goals {
|
||||
}
|
||||
}
|
||||
|
||||
const methods = remoteMethods(new Goals())
|
||||
const methods = remoteMethods(new Goals(new Context()))
|
||||
const actual = JSON.stringify(methods)
|
||||
const expected = JSON.stringify([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bindTypeRTGateway,
|
||||
GatewayService,
|
||||
Remote,
|
||||
RemoteContext,
|
||||
remoteMethods,
|
||||
@@ -16,9 +18,11 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
}
|
||||
|
||||
describe('type-meta Remote declarations', () => {
|
||||
it('executes standard decorator syntax through the Vitest source transform', () => {
|
||||
class Goals {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
it('binds a GatewayService name and executes decorators through the Vitest source transform', async () => {
|
||||
class Goals extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'goals')
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(value: string): string {
|
||||
@@ -31,11 +35,26 @@ describe('type-meta Remote declarations', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const goals = new Goals()
|
||||
class NamespacedGoals extends GatewayService {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'internalGoals', { namespace: 'goals' })
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
const goals = new Goals(ctx)
|
||||
const namespaced = new NamespacedGoals(ctx)
|
||||
expect(goals.typertGateway).toEqual({ service: goals, serviceKey: 'goals', namespace: 'goals' })
|
||||
expect(namespaced.typertGateway).toEqual({
|
||||
service: namespaced,
|
||||
serviceKey: 'internalGoals',
|
||||
namespace: 'goals',
|
||||
})
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('executes standard decorator syntax through the TSX source launcher', () => {
|
||||
|
||||
Reference in New Issue
Block a user