refactor(typert): bind remote services through base class

This commit is contained in:
imccyu
2026-08-06 23:04:25 +08:00
parent 1ea5507bf8
commit e8f2ab89bb
17 changed files with 213 additions and 61 deletions

View File

@@ -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(

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 协议

View File

@@ -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.

View File

@@ -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' } },

View File

@@ -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', () => {