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:
@@ -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)
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
@@ -10,9 +11,32 @@ import type {
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { ClientRemote } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only forwarded Host event.
|
||||
* @param namespace - marker payload recorded by listeners.
|
||||
*/
|
||||
'fixture/changed'(namespace: string): void
|
||||
/**
|
||||
* Test-only forwarded Host event nobody subscribes to.
|
||||
* @param count - marker payload never observed.
|
||||
*/
|
||||
'fixture/idle'(count: number): void
|
||||
/**
|
||||
* Test-only event the Host assembly does not forward.
|
||||
* @param flag - marker payload never delivered.
|
||||
*/
|
||||
'fixture/unselected'(flag: boolean): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
@@ -43,6 +67,19 @@ type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
// Compile-time contract of `$on`: the key face is the forwarding selection and
|
||||
// the listener signature is the owning package's own Cordis declaration.
|
||||
function remoteEventContracts(remote: ClientRemote): void {
|
||||
remote.$on('fixture/changed', (namespace) => { void namespace })
|
||||
// @ts-expect-error -- declared in Events but outside the forwarding selection.
|
||||
remote.$on('fixture/unselected', () => {})
|
||||
// @ts-expect-error -- not declared in Events at all.
|
||||
remote.$on('fixture/absent', () => {})
|
||||
// @ts-expect-error -- the listener signature comes from the event declaration.
|
||||
remote.$on('fixture/changed', (count: number) => { void count })
|
||||
}
|
||||
void remoteEventContracts
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
@@ -96,11 +133,19 @@ function contextDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const { ctx } = await benchFiber(call)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function benchFiber(
|
||||
call: ConnectionHandle['rpc']['call'],
|
||||
): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
const client = ctx.plugin({ inject, apply })
|
||||
await client
|
||||
return { ctx, client }
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
@@ -570,4 +615,64 @@ describe('Client TypeRT API', () => {
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
|
||||
it('owns each $on subscription in the calling fiber', async () => {
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
const subscriber = ctx.plugin(Object.assign(
|
||||
(scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await subscriber
|
||||
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['settings'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await subscriber.dispose()
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['after fiber disposal'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await client.dispose()
|
||||
expect(ctx.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('isolates a throwing listener from the rest of the same event', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: string[] = []
|
||||
const disposeFirst = ctx.remote.$on('fixture/changed', () => {
|
||||
throw new Error('fixture listener failure')
|
||||
})
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
try {
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['credentials'])
|
||||
|
||||
expect(seen).toEqual(['credentials'])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'client api: Remote event "fixture/changed" listener threw:',
|
||||
expect.any(Error),
|
||||
)
|
||||
disposeFirst()
|
||||
ctx.emit('remote/host-event', 'fixture/changed', ['commands'])
|
||||
expect(seen).toEqual(['credentials', 'commands'])
|
||||
expect(consoleError).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes subscription as the only forwarded-event verb', () => {
|
||||
expectTypeOf<ClientRemote>().toHaveProperty('$on')
|
||||
expectTypeOf<ClientRemote>().not.toHaveProperty('$dispatch')
|
||||
})
|
||||
|
||||
it('drops a forwarded event nobody subscribes to', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
|
||||
ctx.emit('remote/host-event', 'fixture/idle', [1])
|
||||
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -47,26 +51,35 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,22 @@ import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
// The forwarded-event allowlist's selection seat: without it in the consumer's
|
||||
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
|
||||
export type { ApiRemoteForwardedEvent } from '../types.ts'
|
||||
// The owner packages' client-safe `./types` exports supply the `Events`
|
||||
// signatures `$on` hands to a listener, so a consumer reads the very
|
||||
// declaration the Host emits rather than a flattened restatement of it.
|
||||
export type {} from '@deepseek-ai/dsh-commands/types'
|
||||
export type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
export type {} from '@deepseek-ai/dsh-settings/types'
|
||||
/**
|
||||
* The Gateway Client face's own declaration merges, type-only: the internal
|
||||
* `remote/host-event` delivery event a carrier owner emits and the Remote
|
||||
* service subscribes to. Erased at emit, so this facade still carries no
|
||||
* runtime edge to the Gateway implementation.
|
||||
*/
|
||||
export type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
// import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta'
|
||||
// import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
|
||||
// // The owner packages' client-safe `./types` exports carry the cordis `Events`
|
||||
// // declarations for every allowlisted event. Pulling them into this face is what
|
||||
// // makes the shape assertion below judge real signatures rather than an empty
|
||||
// // event vocabulary.
|
||||
// import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
// import type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
// import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
@@ -13,6 +24,18 @@ export type {
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
export { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
export type { ApiRemoteForwardedEvent } from './types.ts'
|
||||
|
||||
// Shape gate over the allowlist, kept in the Host face because the Host's event
|
||||
// vocabulary is the authoritative one. It pins three things at compile time:
|
||||
// every entry NAMES a declared event (the predicate is keyed on `keyof
|
||||
// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
|
||||
// `unknown`, which is how "must not depend on AgentScope" is stated statically),
|
||||
// and every entry is ONE-WAY (a waterfall or bail shape returns something other
|
||||
// than void and is excluded). Widening the array to an event that fails any of
|
||||
// these fails here, not on the wire.
|
||||
// API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
@@ -11,8 +11,38 @@ export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
/** The allowlist as a lookup over the live dispatch stream's plain event names. */
|
||||
const FORWARDED_EVENTS: ReadonlySet<string> = new Set(API_REMOTE_FORWARDED_EVENTS)
|
||||
|
||||
/**
|
||||
* Judge one observed dispatch of an allowlisted event against what verbatim
|
||||
* forwarding can carry. The Host face's `TypeRTForwardableEvent` assertion
|
||||
* judges each name's DECLARED signature; only the dispatch stream shows how a
|
||||
* producer actually emitted it, and neither deviation below is visible to the
|
||||
* compiler. A Scope carrier would be silently dropped on the way to a consumer
|
||||
* because `ctx.remote.$on` has no scoped form, and a waterfall or bail dispatch
|
||||
* expects a return value that a one-way carrier can never deliver back.
|
||||
* @param mode - dispatch mode reported by the event bus.
|
||||
* @param event - dispatched event name.
|
||||
* @param carrier - the dispatch `this`; `null` when the event is unscoped.
|
||||
* @param fail - reporter bound to this package.
|
||||
*/
|
||||
function validateDispatch(mode: string, event: string, carrier: unknown, fail: InvariantFailure): void {
|
||||
if (!FORWARDED_EVENTS.has(event)) return
|
||||
if (carrier !== null) {
|
||||
fail(`forwarded host event "${event}" was dispatched with a Scope carrier, which consumers can never receive`)
|
||||
}
|
||||
if (mode !== 'emit') {
|
||||
fail(`forwarded host event "${event}" was dispatched as "${mode}", but forwarding to consumers is one-way`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the forwarded-event dispatch-shape check over the live event bus. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/dispatch', (mode, event, _args, thisArg) => {
|
||||
validateDispatch(mode, event, thisArg, fail)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -21,4 +51,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
29
packages/api/remotes/src/types.ts
Normal file
29
packages/api/remotes/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The one home of this application's forwarded-Host-event allowlist, listed in
|
||||
* `tsconfig.host.json` AND `tsconfig.client.json` so the Host forwarding loop
|
||||
* and the consumer `ctx.remote.$on` key face read the same declaration instead
|
||||
* of two copies that could drift.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-remotes/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host events this application forwards to consumers verbatim: no projection,
|
||||
* no redaction, no renaming. The wire name is the Host cordis event name and
|
||||
* the payload is its argument list, so this array is simultaneously the whole
|
||||
* control point over what a consumer can receive and the legal key set of
|
||||
* `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
|
||||
* else.
|
||||
*/
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
|
||||
/** Type projection of the allowlist; the consumer and the Host read this one. */
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
65
packages/api/remotes/tests/invariant.spec.ts
Normal file
65
packages/api/remotes/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from '@deepseek-ai/dsh-api-remotes'
|
||||
import type { ApiRemoteForwardedEvent } from '@deepseek-ai/dsh-api-remotes'
|
||||
import * as ApiRemotesInvariant from '@deepseek-ai/dsh-api-remotes/invariant'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApiRemotesInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* One legal emission per allowlisted event. `Events` types each emit by name,
|
||||
* so the three arities (0, 2, 1) cannot share a single loop body; keying the
|
||||
* table by {@link ApiRemoteForwardedEvent} makes the compiler reject it as soon
|
||||
* as the allowlist grows, which keeps "every listed event is exercised" true
|
||||
* without a single argument-list assertion.
|
||||
*/
|
||||
const legalEmission: Record<ApiRemoteForwardedEvent, (ctx: Context) => void> = {
|
||||
'commands/change': ctx => { ctx.emit('commands/change') },
|
||||
'credentials/updated': ctx => { ctx.emit('credentials/updated', credentialRef('DEMO_TOKEN')) },
|
||||
'settings/document-updated': ctx => {
|
||||
ctx.emit('settings/document-updated', settingsNamespace('demo'), 1)
|
||||
},
|
||||
}
|
||||
|
||||
describe('forwarded host event invariants', () => {
|
||||
it('accepts an unscoped one-way dispatch of every allowlisted event', async () => {
|
||||
const ctx = await setup()
|
||||
for (const event of API_REMOTE_FORWARDED_EVENTS) {
|
||||
expect(() => { legalEmission[event](ctx) }).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an owner package event the allowlist does not select', async () => {
|
||||
const ctx = await setup()
|
||||
// `settings/updated` is the resolved-value event, deliberately left out of
|
||||
// the allowlist while its sibling `settings/document-updated` is in it, so
|
||||
// this pins that the check discriminates by name rather than by owner.
|
||||
expect(() => {
|
||||
ctx.emit('settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
|
||||
}).not.toThrow()
|
||||
// The carrier that fails an allowlisted event must pass unremarked here.
|
||||
expect(() => {
|
||||
ctx.emit({}, 'settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update')
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an allowlisted event dispatched with a Scope carrier', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit({}, 'commands/change') })
|
||||
.toThrow(/"commands\/change" was dispatched with a Scope carrier/)
|
||||
})
|
||||
|
||||
it('rejects an allowlisted event dispatched as anything but one-way', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.bail('commands/change') })
|
||||
.toThrow(/"commands\/change" was dispatched as "bail"/)
|
||||
})
|
||||
})
|
||||
@@ -6,15 +6,28 @@
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
"src/client/index.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../gateway"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
"src/invariant.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
@@ -20,9 +21,18 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user