feat(remote): deliver allowlisted Host events through ctx.remote.$on

api/remotes owns the allowlist and its type projection; type-meta owns the shape
predicate, the selection seat, and the internal remote/host-event carrier
signal; api/gateway's Client half turns that signal into $on callbacks through a
private dispatch. apiproxy forwards each allowlisted emission verbatim in one
host/remote-event frame, registered ahead of the derived invalidation frames so
frame order is unchanged, and drops the three per-event variants it replaces.
Owner packages move their Events declarations into client-safe ./types exports,
so a consumer's listener signature is the Host's own declaration.
This commit is contained in:
imccyu
2026-08-10 21:32:50 +08:00
parent b64da061a8
commit d88f771e19
59 changed files with 956 additions and 257 deletions
+3
View File
@@ -49,6 +49,7 @@ export type {
TypeRTContextRegistry,
TypeRTContextWire,
TypeRTDisposer,
TypeRTForwardableEvent,
TypeRTHostContextProvider,
TypeRTHostContextResolver,
TypeRTLocalRegistry,
@@ -64,6 +65,8 @@ export type {
TypeRTRemoteScopeMap,
TypeRTRemoteScopeNamespace,
TypeRTRemoteContribution,
TypeRTRemoteEvent,
TypeRTRemoteEventSelection,
TypeRTRemoteMap,
TypeRTRemoteNamespace,
TypeRTRemoteNamespaceMap,
+42 -1
View File
@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-type-meta/types
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
declare const LOOKUP_HOST: unique symbol
declare const LOOKUP_WIRE: unique symbol
@@ -42,6 +42,24 @@ export interface TypeRTRemoteMap {}
/** Merge-extensible scoped Remote method signatures generated for consumers. */
export interface TypeRTRemoteScopeMap {}
/**
* Cordis event names whose shape a one-way Remote delivery can carry: unbound
* from any Scope and returning `void`. Which ones are actually forwarded is the
* Host assembly's selection; this predicate only excludes shapes the carrier
* cannot represent.
*/
export type TypeRTForwardableEvent = {
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
? ReturnType<Events[Event]> extends void ? Event : never
: never
}[keyof Events]
/** Merge-extensible forwarding selection declared once by the Host assembly. */
export interface TypeRTRemoteEventSelection {}
/** Legal `$on` keys: selected events that exist in the current compilation face. */
export type TypeRTRemoteEvent = Extract<keyof Events, keyof TypeRTRemoteEventSelection>
/**
* Resolve one direct Remote namespace from the generated flat endpoint map.
* @template Namespace - wire namespace before the endpoint slash.
@@ -184,6 +202,15 @@ export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap {
* @returns disposer after namespace services and concrete methods are ready.
*/
$mount(contribution: TypeRTRemoteContribution): Promise<TypeRTDisposer>
/**
* Subscribe to one forwarded Host event; delivery is one-way, in registration
* order, and isolates a throwing listener from the rest.
* @template Event - forwarded event name selected by the Host assembly.
* @param event - forwarded Host event name, unchanged on the wire.
* @param listener - receives the Host's argument list as declared by Cordis `Events`.
* @returns disposer owned by the calling fiber.
*/
$on<Event extends TypeRTRemoteEvent>(event: Event, listener: Events[Event]): () => void
}
/**
@@ -425,4 +452,18 @@ declare module '@deepseek-ai/cordis' {
interface Context {
typert: TypeRTService
}
interface Events {
/**
* The carrier received one allowlisted host event forwarded over the wire.
* Declared here because both compilation faces share this package; only the
* consumer side participates, where the Client half owning the host frame
* sink emits it and the Remote service is its only subscriber, turning it
* into `$on` callbacks. The Host neither emits nor observes it.
* @mode emit
* @param event - forwarded host event name, exactly as the Host emitted it.
* @param args - the Host argument list, already JSON-decoded.
*/
'remote/host-event'(event: string, args: readonly unknown[]): void
}
}
@@ -1,7 +1,7 @@
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, expectTypeOf, it } from 'vitest'
import {
bindTypeRTGateway,
GatewayService,
@@ -9,12 +9,38 @@ import {
RemoteScope,
remoteMethods,
type TypeRTContext,
type TypeRTForwardableEvent,
type TypeRTRemoteEvent,
} from '@deepseek-ai/dsh-type-meta'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Test-only one-way event: bound to no Scope and returning nothing.
* @param value - marker payload.
*/
'meta-fixture/forwardable'(value: string): void
/**
* Test-only Scope-bound event, which no carrier can deliver one-way.
* @param value - marker payload.
*/
'meta-fixture/scoped'(this: Context, value: string): void
/**
* Test-only answered event, whose result no one-way delivery can return.
* @param value - marker payload.
* @returns the replacement value.
*/
'meta-fixture/answered'(value: string): string
}
}
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTContextMap {
metaFixture: TypeRTContext<string>
}
interface TypeRTRemoteEventSelection extends
Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {}
}
describe('type-meta Remote declarations', () => {
@@ -209,6 +235,16 @@ describe('type-meta Remote declarations', () => {
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace')
expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
})
it('admits only one-way event shapes and only selected events that exist', () => {
expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypeRTForwardableEvent>()
expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypeRTForwardableEvent>()
expectTypeOf<'meta-fixture/answered'>().not.toExtend<TypeRTForwardableEvent>()
expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypeRTRemoteEvent>()
expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypeRTRemoteEvent>()
expectTypeOf<'meta-fixture/absent'>().not.toExtend<TypeRTRemoteEvent>()
})
})
function methodContext<This extends object>(