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
+51 -1
View File
@@ -5,7 +5,7 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
@@ -13,6 +13,7 @@ import type {
TypeRTCodec,
TypeRTDisposer,
TypeRTRemoteContribution,
TypeRTRemoteEvent,
} from '@deepseek-ai/dsh-type-meta'
interface MountToken {
@@ -71,14 +72,20 @@ export function apply(ctx: Context): void {
new ClientRemoteService(ctx)
}
/** One subscribed listener after `$on` erased its per-event argument list. */
type RemoteEventListener = (...args: never[]) => void
class ClientRemoteService extends Service implements TypeRTClientRemote {
private readonly ownerCtx: Context
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
private readonly subscriptions = new Map<string, Set<RemoteEventListener>>()
private mutations = Promise.resolve()
constructor(ctx: Context) {
super(ctx, 'remote')
this.ownerCtx = ctx
ctx.on('remote/host-event', (event, args) => { this.dispatch(event, args) })
ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
}
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
@@ -91,6 +98,49 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
return async () => { await owned() }
}
$on<Event extends TypeRTRemoteEvent>(
event: Event,
listener: Events[Event],
): ReturnType<TypeRTClientRemote['$on']> {
// The table is keyed by the runtime event name, so the argument list this
// signature pins per event cannot survive in it; `$deliver` restores it
// from the frame the Host emitted for that same name.
const erased: RemoteEventListener = listener
const owned = this.ctx.effect(() => {
const listeners = this.listeners(event)
listeners.add(erased)
return () => { listeners.delete(erased) }
}, `api-gateway.client.$on(${JSON.stringify(event)})`)
return () => { void owned() }
}
/**
* Deliver one forwarded event in registration order, isolating a throwing
* listener; an event name nobody subscribes to is dropped, since the wire
* carries whatever the Host forwarding allowlist selected.
*/
private dispatch(event: string, args: readonly unknown[]): void {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
for (const listener of listeners) {
try {
listener(...args as never[])
} catch (error) {
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
}
}
}
/** Subscription set for one event name; empty sets are retained, bounded by the Host's selection. */
private listeners(event: string): Set<RemoteEventListener> {
let listeners = this.subscriptions.get(event)
if (listeners === undefined) {
listeners = new Set()
this.subscriptions.set(event, listeners)
}
return listeners
}
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
const result = this.mutations.then(operation, operation)
this.mutations = result.then(() => undefined, () => undefined)
+2 -1
View File
@@ -16,7 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
* state, while Client methods and descriptors mutate in one owned effect.
* state, while Client methods, descriptors, and `$on` subscriptions mutate in
* one owned effect.
*/
const install: InvariantInstaller = () => {}