mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(api-gateway): unify Remote streams and events
This commit is contained in:
@@ -1,17 +1,15 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import '@deepseek-ai/dsh-user-questions'
|
||||
|
||||
/** Snapshot-only provider whose invocation means the child guard failed. */
|
||||
/** Snapshot-only answerer whose invocation means the child guard failed. */
|
||||
export const name = 'child-question-tripwire'
|
||||
|
||||
/** User-interaction service required by the tripwire provider. */
|
||||
/** User-interaction service required by the tripwire answerer. */
|
||||
export const inject = ['userQuestions']
|
||||
|
||||
/** Register a provider that must remain unreachable for the delegated call. */
|
||||
/** Register an answerer that must remain unreachable for the delegated call. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.userQuestions.registerProvider({
|
||||
async ask() {
|
||||
throw new Error('snapshot tripwire: delegated question reached the UI provider')
|
||||
},
|
||||
ctx.on('user-questions/request', async () => {
|
||||
throw new Error('snapshot tripwire: delegated question reached the UI answerer')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConnectionHandle,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypertClientEventListener,
|
||||
TypertClientRemote,
|
||||
RemoteResult,
|
||||
TypertCodec,
|
||||
@@ -16,6 +19,29 @@ import type {
|
||||
TypertRemoteContribution,
|
||||
TypertRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamError,
|
||||
RemoteStreamMuxClient,
|
||||
} from './stream-client.ts'
|
||||
import { ClientRemoteEvents } from './remote-events.ts'
|
||||
import {
|
||||
RemoteStream,
|
||||
type RemoteStreamOptions,
|
||||
} from './remote-stream.ts'
|
||||
|
||||
export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts'
|
||||
export { RemoteJournalStream } from './journal-stream.ts'
|
||||
export type {
|
||||
RemoteJournalChange,
|
||||
RemoteJournalFrame,
|
||||
RemoteJournalStreamOptions,
|
||||
RemoteStreamFactory,
|
||||
} from './journal-stream.ts'
|
||||
export { RemoteStream } from './remote-stream.ts'
|
||||
export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts'
|
||||
export { RemoteSnapshotStream } from './snapshot-stream.ts'
|
||||
export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts'
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
@@ -47,11 +73,21 @@ interface BoundContextIdentity {
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface PreparedClientInvocation {
|
||||
readonly endpoint: string
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
interface RemoteNamespaceHandle {
|
||||
readonly service: RemoteNamespaceService
|
||||
readonly dispose: TypertDisposer
|
||||
}
|
||||
|
||||
interface LoaderReadiness {
|
||||
await(): Promise<unknown>
|
||||
}
|
||||
|
||||
/** One descriptor's mounted variants, for the group disposer to unwind. */
|
||||
interface InstalledMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
@@ -60,8 +96,15 @@ interface InstalledMethod {
|
||||
scoped: boolean
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypertClientRemote
|
||||
/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */
|
||||
export interface ClientRemote extends TypertClientRemote {
|
||||
/**
|
||||
* Create one independently cancellable, reconnecting logical stream.
|
||||
* @param options - domain-owned opener and generation-end classification.
|
||||
* @returns a single-consumer stream annotated with physical generation ids.
|
||||
*/
|
||||
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -81,28 +124,46 @@ 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
|
||||
|
||||
/**
|
||||
* One subscription, identified by the registration rather than by its listener:
|
||||
* two fibers may subscribe the same function object to the same event, and each
|
||||
* disposer must retire only its own registration.
|
||||
*/
|
||||
interface RemoteEventSubscription {
|
||||
readonly listener: RemoteEventListener
|
||||
}
|
||||
|
||||
class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
class ClientRemoteService extends Service implements ClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly connection: ConnectionHandle
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private readonly subscriptions = new Map<string, RemoteEventSubscription[]>()
|
||||
private readonly streams = new RemoteStreamMuxClient()
|
||||
private readonly events: ClientRemoteEvents
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
this.connection = connection
|
||||
this.events = new ClientRemoteEvents(
|
||||
ctx,
|
||||
connection,
|
||||
(endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal),
|
||||
)
|
||||
if (connection.rpc.open === undefined) this.streams.start()
|
||||
let disposed = false
|
||||
let loop: ReturnType<ConnectionHandle['start']> | undefined
|
||||
const start = (): void => {
|
||||
if (disposed) return
|
||||
loop = connection.start({
|
||||
onConnected: () => { this.ownerCtx.emit('connection/reset') },
|
||||
})
|
||||
}
|
||||
const loader = ctx.get('loader') as LoaderReadiness | undefined
|
||||
if (loader === undefined) start()
|
||||
else void loader.await().then(start, () => {})
|
||||
ctx.effect(() => async () => {
|
||||
disposed = true
|
||||
loop?.stop()
|
||||
await this.events.dispose()
|
||||
await this.streams.close()
|
||||
}, 'api-gateway.client.transport')
|
||||
}
|
||||
|
||||
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
|
||||
return new RemoteStream(this.connection, options)
|
||||
}
|
||||
|
||||
async $mount(contribution: TypertRemoteContribution): ReturnType<TypertClientRemote['$mount']> {
|
||||
@@ -117,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
|
||||
$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 subscription: RemoteEventSubscription = { listener }
|
||||
const owned = this.ctx.effect(() => {
|
||||
const listeners = this.listeners(event)
|
||||
listeners.push(subscription)
|
||||
return () => {
|
||||
const at = listeners.indexOf(subscription)
|
||||
/* v8 ignore next -- listener */
|
||||
if (at >= 0) listeners.splice(at, 1)
|
||||
}
|
||||
}, `api-gateway.client.$on(${JSON.stringify(event)})`)
|
||||
return () => { void owned() }
|
||||
listener: TypertClientEventListener<Event>,
|
||||
): () => void {
|
||||
return this.events.subscribe(this.ctx, event, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded event in registration order, isolating a listener
|
||||
* that fails either synchronously or by rejecting a returned promise; see
|
||||
* {@link TypertClientRemote.$dispatch} for the caller contract.
|
||||
*/
|
||||
$dispatch(event: string, args: readonly unknown[]): void {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
// Snapshot: a listener may subscribe or dispose during delivery, and this
|
||||
// round's recipients are the ones registered when the frame arrived.
|
||||
for (const { listener } of [...listeners]) {
|
||||
const report = (error: unknown): void => {
|
||||
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
|
||||
}
|
||||
try {
|
||||
/* oxlint-disable-next-line typescript/no-confusing-void-expression --
|
||||
* The declared return is void, so nobody awaits an async listener; the
|
||||
* runtime value is still a promise, and reading it is the only way to
|
||||
* keep its rejection inside this containment instead of surfacing as an
|
||||
* unhandled one. */
|
||||
const settled: unknown = listener(...args as never[])
|
||||
if (settled instanceof Promise) settled.catch(report)
|
||||
} catch (error) {
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */
|
||||
private listeners(event: string): RemoteEventSubscription[] {
|
||||
let listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) {
|
||||
listeners = []
|
||||
this.subscriptions.set(event, listeners)
|
||||
}
|
||||
return listeners
|
||||
/** Open one Remote stream and normalize a worker-local carrier's structural failures. */
|
||||
private openRemoteStream(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
noConnection = `client api: ${endpoint} has no active Connection`,
|
||||
): AsyncIterable<unknown> {
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(noConnection)
|
||||
const local = connection.rpc.open?.('/api', endpoint, payload, signal)
|
||||
return local === undefined
|
||||
? this.streams.open(endpoint, payload, signal)
|
||||
: normalizeConnectionStream(local)
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
@@ -331,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<RemoteResult<unknown>> {
|
||||
): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = adapter?.identity(callerCtx)
|
||||
if (identity !== undefined) {
|
||||
return this.invoke(
|
||||
return this.invokeSelected(
|
||||
scoped.descriptor,
|
||||
scoped.projection,
|
||||
scoped.token,
|
||||
@@ -347,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
}
|
||||
}
|
||||
if (direct !== undefined) {
|
||||
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
}
|
||||
if (scoped !== undefined) {
|
||||
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
return this.invokeSelected(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
}
|
||||
throw new Error('client api: Remote method is no longer mounted')
|
||||
}
|
||||
|
||||
private invokeSelected(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<RemoteResult<unknown>> | AsyncIterable<unknown> {
|
||||
if (descriptor.mode === 'stream') {
|
||||
return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
}
|
||||
return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
@@ -365,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
): Promise<RemoteResult<unknown>> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) return withdrawn(endpoint)
|
||||
const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
try {
|
||||
const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal)
|
||||
if (!mountActive(token)) return withdrawn(endpoint)
|
||||
if (!result.ok) return { ok: false, error: result.error }
|
||||
return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
|
||||
} catch (error) {
|
||||
// Carrier throws (offline, abort, a rejected result payload) are outcomes
|
||||
// of the call, not assembly faults, so they join the same error branch.
|
||||
return carrierFailure(endpoint, error)
|
||||
}
|
||||
}
|
||||
|
||||
private async *invokeStream(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): AsyncGenerator {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(withdrawn(endpoint).error.message)
|
||||
const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity)
|
||||
const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal)
|
||||
for await (const value of stream) {
|
||||
if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message)
|
||||
yield parse(descriptor.result, value, endpoint, 'result')
|
||||
}
|
||||
}
|
||||
|
||||
private prepareInvocation(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): PreparedClientInvocation {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
@@ -377,14 +458,14 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = boundIdentity === undefined
|
||||
const adapter = boundIdentity === undefined
|
||||
? this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
: undefined
|
||||
if (boundIdentity === undefined && binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
if (boundIdentity === undefined && adapter === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = boundIdentity === undefined
|
||||
? binder?.identity(callerCtx)
|
||||
? adapter?.identity(callerCtx)
|
||||
: boundIdentity.value
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
@@ -398,22 +479,11 @@ class ClientRemoteService extends Service implements TypertClientRemote {
|
||||
if (value !== undefined) args[parameter.wire] = value
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
try {
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) return withdrawn(endpoint)
|
||||
if (!result.ok) return { ok: false, error: result.error }
|
||||
return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
|
||||
} catch (error) {
|
||||
// Carrier throws (offline, abort, a rejected result payload) are outcomes
|
||||
// of the call, not assembly faults, so they join the same error branch.
|
||||
return carrierFailure(endpoint, error)
|
||||
}
|
||||
return { endpoint, args, signal }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +492,7 @@ type InvokeRemote = (
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<RemoteResult<unknown>>
|
||||
) => Promise<RemoteResult<unknown>> | AsyncIterable<unknown>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
@@ -477,7 +547,7 @@ class RemoteNamespaceService extends Service {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
@@ -620,14 +690,37 @@ function parse(codec: TypertCodec, value: unknown, endpoint: string, field: stri
|
||||
}
|
||||
|
||||
/** The namespace retired before or during the call, so no request outcome exists. */
|
||||
function withdrawn(endpoint: string): RemoteResult<never> {
|
||||
function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
}
|
||||
|
||||
function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
|
||||
function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
function internalFailure(message: string): RemoteResult<never> {
|
||||
function internalFailure(message: string): Extract<RemoteResult<never>, { readonly ok: false }> {
|
||||
return { ok: false, error: { code: 'internal', message, details: {} } }
|
||||
}
|
||||
|
||||
type MarkedConnectionStreamFailure = Error & {
|
||||
readonly dshRemoteStreamFailure?:
|
||||
| { readonly kind: 'remote'; readonly code: string; readonly details: object }
|
||||
| { readonly kind: 'carrier' }
|
||||
}
|
||||
|
||||
/** Preserve Gateway error classes across a worker transport's separately bundled page half. */
|
||||
async function *normalizeConnectionStream(source: AsyncIterable<unknown>): AsyncGenerator {
|
||||
try {
|
||||
yield * source
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) throw error
|
||||
const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure
|
||||
if (marker?.kind === 'remote') {
|
||||
throw new RemoteStreamError(marker.code, error.message, marker.details)
|
||||
}
|
||||
if (marker?.kind === 'carrier') {
|
||||
throw new RemoteStreamCarrierError(error.message, { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */
|
||||
|
||||
import { RemoteStreamCarrierError } from './stream-client.ts'
|
||||
import type {
|
||||
RemoteStream,
|
||||
RemoteStreamItem,
|
||||
RemoteStreamOptions,
|
||||
} from './remote-stream.ts'
|
||||
|
||||
/** Transport-neutral opening cursor or journal entry. */
|
||||
export type RemoteJournalFrame<Entry, Cursor> =
|
||||
| { readonly type: 'opened'; readonly cursor: Cursor }
|
||||
| { readonly type: 'entry'; readonly entry: Entry }
|
||||
|
||||
/** One committed journal-window update. */
|
||||
export type RemoteJournalChange<Page, Entry> =
|
||||
| {
|
||||
readonly type: 'replace'
|
||||
readonly page: Page
|
||||
readonly entries: readonly Entry[]
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
| {
|
||||
readonly type: 'prepend'
|
||||
readonly page: Page
|
||||
readonly entries: readonly Entry[]
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
| { readonly type: 'append'; readonly entry: Entry }
|
||||
|
||||
type JournalStreamItem<Entry, Cursor> = RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>
|
||||
|
||||
/** Gateway capability used to create one reconnecting Remote stream. */
|
||||
export interface RemoteStreamFactory {
|
||||
/**
|
||||
* Create one independently cancellable logical stream.
|
||||
* @param options - domain-owned opener and generation-end classification.
|
||||
* @returns a reconnecting single-consumer stream.
|
||||
*/
|
||||
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item>
|
||||
}
|
||||
|
||||
/** Domain publication and cursor operations for one addressed journal stream. */
|
||||
export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
|
||||
/** Diagnostic stream name used in protocol failures. */
|
||||
readonly name: string
|
||||
/** Cursor representing a journal with no entries. */
|
||||
readonly emptyCursor: Cursor
|
||||
/** Read the ordered entries carried by a page. */
|
||||
readonly entries: (page: Page) => readonly Entry[]
|
||||
/** Read whether an older page exists. */
|
||||
readonly hasMore: (page: Page) => boolean
|
||||
/** Read one entry's durable cursor. */
|
||||
readonly cursor: (entry: Entry) => Cursor
|
||||
/** Compare two cursors. */
|
||||
readonly compare: (left: Cursor, right: Cursor) => number
|
||||
/** Test whether the right cursor immediately follows the left cursor. */
|
||||
readonly follows: (left: Cursor, right: Cursor) => boolean
|
||||
/** Apply one complete journal-window change. */
|
||||
readonly publish: (change: RemoteJournalChange<Page, Entry>) => void
|
||||
/** Observe a retryable carrier loss before reconnection. */
|
||||
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
|
||||
/** Publish a terminal stream, page, or protocol failure after opening. */
|
||||
readonly failed: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns follow-before-page opening, ordered live delivery, pagination, and repair.
|
||||
*
|
||||
* The domain retains its published window during reconnection. A replacement is
|
||||
* published only after a tail page reaches the generation's opening cursor.
|
||||
*/
|
||||
export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = void> {
|
||||
private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor>>
|
||||
private initialRequest!: PageRequest
|
||||
private hasInitialRequest = false
|
||||
private resumeCursor: Cursor | undefined
|
||||
private hasResumeCursor = false
|
||||
private generation = 0
|
||||
private firstCursor: Cursor | undefined
|
||||
private lastCursor: Cursor | undefined
|
||||
private started = false
|
||||
private opened = false
|
||||
private disposed = false
|
||||
private done: Promise<void> | undefined
|
||||
private closing: Promise<void> | undefined
|
||||
private pendingNext: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> | undefined
|
||||
|
||||
/**
|
||||
* @param remote - Gateway factory for the reconnecting physical-generation stream.
|
||||
* @param options - cursor algebra and domain publication sinks.
|
||||
*/
|
||||
protected constructor(
|
||||
remote: RemoteStreamFactory,
|
||||
private readonly options: RemoteJournalStreamOptions<Page, Entry, Cursor>,
|
||||
) {
|
||||
this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor>>({
|
||||
name: options.name,
|
||||
open: signal => this.follow(
|
||||
this.hasResumeCursor ? this.resumeCursor : undefined,
|
||||
signal,
|
||||
),
|
||||
ended: accepted => accepted
|
||||
? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`)
|
||||
: new Error(
|
||||
`${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`,
|
||||
),
|
||||
...(options.carrierFailed === undefined
|
||||
? {}
|
||||
: { carrierFailed: options.carrierFailed }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one physical journal generation after the last accepted cursor.
|
||||
* @param after - last accepted cursor, or `undefined` for the initial generation.
|
||||
* @param signal - cancellation lifetime of the physical generation.
|
||||
* @returns opening cursor followed by live entries.
|
||||
*/
|
||||
protected abstract follow(
|
||||
after: Cursor | undefined,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<RemoteJournalFrame<Entry, Cursor>>
|
||||
|
||||
/**
|
||||
* Read one journal page through the addressed domain source.
|
||||
* @param request - domain page request.
|
||||
* @param signal - cancellation lifetime shared with the logical stream.
|
||||
* @returns the requested page.
|
||||
*/
|
||||
protected abstract readPage(request: PageRequest, signal: AbortSignal): Promise<Page>
|
||||
|
||||
/**
|
||||
* Derive an unbounded-tail request from the initial page request.
|
||||
* @param initial - request used to open the journal window.
|
||||
* @returns request suitable for reconnect and gap repair.
|
||||
*/
|
||||
protected abstract repairRequest(initial: PageRequest): PageRequest
|
||||
|
||||
/** Cancellation lifetime shared by follow and page calls. */
|
||||
get signal(): AbortSignal {
|
||||
return this.stream.signal
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish follow before reading and publishing the initial page.
|
||||
* @param request - initial tail-page request.
|
||||
* @returns after the first complete window is published.
|
||||
*/
|
||||
async open(request: PageRequest): Promise<void> {
|
||||
if (this.started) throw new Error(`${this.options.name} already opened`)
|
||||
this.started = true
|
||||
this.initialRequest = request
|
||||
this.hasInitialRequest = true
|
||||
const iterator = this.stream[Symbol.asyncIterator]()
|
||||
try {
|
||||
const first = await this.takeNext(iterator)
|
||||
if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`)
|
||||
await this.replaceGeneration(request, first.value, iterator, false)
|
||||
this.opened = true
|
||||
this.done = this.consume(iterator)
|
||||
} catch (error) {
|
||||
await this.stream.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and prepend one older page after a successful open.
|
||||
* @param request - domain page request bound to this stream's address.
|
||||
* @returns after the page is applied or rejected as discontinuous.
|
||||
*/
|
||||
async prepend(request: PageRequest): Promise<void> {
|
||||
if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`)
|
||||
const page = await this.readPage(request, this.stream.signal)
|
||||
this.stream.signal.throwIfAborted()
|
||||
const entries = this.options.entries(page)
|
||||
this.assertPage(entries)
|
||||
const before = this.firstCursor
|
||||
const accepted = before === undefined
|
||||
? [...entries]
|
||||
: entries.filter(entry => this.options.compare(this.options.cursor(entry), before) < 0)
|
||||
const tail = accepted.at(-1)
|
||||
if (tail !== undefined && before !== undefined
|
||||
&& !this.options.follows(this.options.cursor(tail), before)) {
|
||||
this.options.publish({ type: 'prepend', page, entries: [], hasMore: false })
|
||||
throw new Error(`${this.options.name} history page is discontinuous`)
|
||||
}
|
||||
const first = accepted[0]
|
||||
if (first !== undefined) this.firstCursor = this.options.cursor(first)
|
||||
this.options.publish({
|
||||
type: 'prepend',
|
||||
page,
|
||||
entries: accepted,
|
||||
hasMore: this.options.hasMore(page),
|
||||
})
|
||||
}
|
||||
|
||||
/** Replace the active physical generation while retaining the published window. */
|
||||
restart(): void {
|
||||
this.stream.restart()
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently stop follow, page requests, and the background consumer.
|
||||
* @returns when no stream work or publication callback can still run.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
if (this.closing !== undefined) return this.closing
|
||||
this.disposed = true
|
||||
const done = this.done
|
||||
const closing = (async () => {
|
||||
await this.stream.dispose()
|
||||
await done
|
||||
})()
|
||||
this.closing = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
private async consume(
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
while (true) {
|
||||
const next = await this.takeNext(iterator)
|
||||
if (next.done) return
|
||||
const item = next.value
|
||||
if (item.generation !== this.generation) {
|
||||
await this.replaceGeneration(this.repairPageRequest(), item, iterator, true)
|
||||
continue
|
||||
}
|
||||
if (item.value.type === 'opened') {
|
||||
throw new Error(`${this.options.name} emitted more than one opening cursor`)
|
||||
}
|
||||
await this.acceptEntry(item, iterator)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.disposed) this.options.failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceGeneration(
|
||||
request: PageRequest,
|
||||
initial: JournalStreamItem<Entry, Cursor>,
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
resumed: boolean,
|
||||
): Promise<void> {
|
||||
let item = initial
|
||||
let isResumed = resumed
|
||||
while (true) {
|
||||
const cursor = this.opening(item, isResumed)
|
||||
this.setResumeCursor(cursor)
|
||||
const superseded = await this.replaceThrough(
|
||||
request,
|
||||
cursor,
|
||||
item.generation,
|
||||
item.signal,
|
||||
iterator,
|
||||
[],
|
||||
)
|
||||
if (superseded === undefined) return
|
||||
item = superseded
|
||||
isResumed = true
|
||||
}
|
||||
}
|
||||
|
||||
private opening(
|
||||
item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor>>,
|
||||
resumed: boolean,
|
||||
): Cursor {
|
||||
if (item.value.type !== 'opened') {
|
||||
throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`)
|
||||
}
|
||||
const cursor = item.value.cursor
|
||||
if (resumed && this.lastCursor !== undefined
|
||||
&& this.options.compare(cursor, this.lastCursor) < 0) {
|
||||
throw new Error(
|
||||
`${this.options.name} resumed at a cursor behind the last applied entry`,
|
||||
)
|
||||
}
|
||||
this.generation = item.generation
|
||||
item.accept()
|
||||
return cursor
|
||||
}
|
||||
|
||||
private async acceptEntry(
|
||||
item: JournalStreamItem<Entry, Cursor>,
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
): Promise<void> {
|
||||
if (item.value.type !== 'entry') {
|
||||
throw new Error(`${this.options.name} emitted more than one opening cursor`)
|
||||
}
|
||||
const entry = item.value.entry
|
||||
const cursor = this.options.cursor(entry)
|
||||
const last = this.lastCursor
|
||||
if (last !== undefined) {
|
||||
if (this.options.compare(cursor, last) <= 0) return
|
||||
if (!this.options.follows(last, cursor)) {
|
||||
const request = this.repairPageRequest()
|
||||
const superseded = await this.replaceThrough(
|
||||
request,
|
||||
cursor,
|
||||
item.generation,
|
||||
item.signal,
|
||||
iterator,
|
||||
[entry],
|
||||
)
|
||||
if (superseded !== undefined) {
|
||||
await this.replaceGeneration(request, superseded, iterator, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.firstCursor === undefined) this.firstCursor = cursor
|
||||
this.lastCursor = cursor
|
||||
this.setResumeCursor(cursor)
|
||||
this.options.publish({ type: 'append', entry })
|
||||
}
|
||||
|
||||
private async replaceThrough(
|
||||
request: PageRequest,
|
||||
requiredCursor: Cursor,
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
queued: Entry[],
|
||||
): Promise<JournalStreamItem<Entry, Cursor> | undefined> {
|
||||
let read = await this.readPageWhileFollowing(request, generation, signal, iterator, queued)
|
||||
if (read.type === 'superseded') return read.item
|
||||
let page = read.page
|
||||
let entries = this.mergeReplacement(page, queued)
|
||||
let target = this.maxCursor(requiredCursor, queued)
|
||||
if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
|
||||
read = await this.readPageWhileFollowing(
|
||||
this.repairPageRequest(),
|
||||
generation,
|
||||
signal,
|
||||
iterator,
|
||||
queued,
|
||||
)
|
||||
if (read.type === 'superseded') return read.item
|
||||
page = read.page
|
||||
entries = this.mergeReplacement(page, queued)
|
||||
target = this.maxCursor(requiredCursor, queued)
|
||||
}
|
||||
if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) {
|
||||
throw new Error(`${this.options.name} page did not reach its opening cursor`)
|
||||
}
|
||||
const first = entries[0]
|
||||
this.firstCursor = first === undefined ? undefined : this.options.cursor(first)
|
||||
this.lastCursor = this.tailCursor(entries)
|
||||
this.setResumeCursor(this.lastCursor)
|
||||
this.options.publish({
|
||||
type: 'replace',
|
||||
page,
|
||||
entries,
|
||||
hasMore: this.options.hasMore(page),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async readPageWhileFollowing(
|
||||
request: PageRequest,
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
queued: Entry[],
|
||||
): Promise<
|
||||
| { readonly type: 'page'; readonly page: Page }
|
||||
| { readonly type: 'superseded'; readonly item: JournalStreamItem<Entry, Cursor> }
|
||||
> {
|
||||
const page = this.readPage(request, signal).then(
|
||||
value => ({ type: 'page' as const, value }),
|
||||
(error: unknown) => ({ type: 'page-error' as const, error }),
|
||||
)
|
||||
while (true) {
|
||||
const pending = this.nextResult(iterator)
|
||||
const next = pending.then(
|
||||
value => ({ type: 'next' as const, value }),
|
||||
(error: unknown) => ({ type: 'next-error' as const, error }),
|
||||
)
|
||||
const result = await Promise.race([page, next])
|
||||
if (result.type === 'page') {
|
||||
signal.throwIfAborted()
|
||||
return { type: 'page', page: result.value }
|
||||
}
|
||||
if (result.type === 'page-error') throw result.error
|
||||
this.releaseNext(pending)
|
||||
if (result.type === 'next-error') throw result.error
|
||||
if (result.value.done) {
|
||||
signal.throwIfAborted()
|
||||
throw new Error(`${this.options.name} ended while reading its replacement page`)
|
||||
}
|
||||
const item = result.value.value
|
||||
if (item.generation !== generation) return { type: 'superseded', item }
|
||||
if (item.value.type === 'opened') {
|
||||
throw new Error(`${this.options.name} emitted more than one opening cursor`)
|
||||
}
|
||||
queued.push(item.value.entry)
|
||||
}
|
||||
}
|
||||
|
||||
private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined {
|
||||
const entries = [...this.options.entries(page)]
|
||||
this.assertPage(entries)
|
||||
const sorted = [...queued].sort((left, right) => (
|
||||
this.options.compare(this.options.cursor(left), this.options.cursor(right))
|
||||
))
|
||||
let tail = this.tailCursor(entries)
|
||||
for (const entry of sorted) {
|
||||
const cursor = this.options.cursor(entry)
|
||||
if (this.options.compare(cursor, tail) <= 0) continue
|
||||
if (!this.options.follows(tail, cursor)) return undefined
|
||||
entries.push(entry)
|
||||
tail = cursor
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor {
|
||||
let result = cursor
|
||||
for (const entry of entries) {
|
||||
const candidate = this.options.cursor(entry)
|
||||
if (this.options.compare(candidate, result) > 0) result = candidate
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private nextResult(
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
|
||||
this.pendingNext ??= iterator.next()
|
||||
return this.pendingNext
|
||||
}
|
||||
|
||||
private async takeNext(
|
||||
iterator: AsyncIterator<JournalStreamItem<Entry, Cursor>>,
|
||||
): Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>> {
|
||||
const pending = this.nextResult(iterator)
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
this.releaseNext(pending)
|
||||
}
|
||||
}
|
||||
|
||||
private releaseNext(pending: Promise<IteratorResult<JournalStreamItem<Entry, Cursor>>>): void {
|
||||
if (this.pendingNext === pending) this.pendingNext = undefined
|
||||
}
|
||||
|
||||
private repairPageRequest(): PageRequest {
|
||||
if (!this.hasInitialRequest) throw new Error(`${this.options.name} has no initial page request`)
|
||||
return this.repairRequest(this.initialRequest)
|
||||
}
|
||||
|
||||
private setResumeCursor(cursor: Cursor): void {
|
||||
this.resumeCursor = cursor
|
||||
this.hasResumeCursor = true
|
||||
}
|
||||
|
||||
private tailCursor(entries: readonly Entry[]): Cursor {
|
||||
const tail = entries.at(-1)
|
||||
return tail === undefined ? this.options.emptyCursor : this.options.cursor(tail)
|
||||
}
|
||||
|
||||
private assertPage(entries: readonly Entry[]): void {
|
||||
for (let index = 1; index < entries.length; index++) {
|
||||
const previous = entries[index - 1]
|
||||
const entry = entries[index]
|
||||
if (previous === undefined || entry === undefined) continue
|
||||
if (!this.options.follows(this.options.cursor(previous), this.options.cursor(entry))) {
|
||||
throw new Error(`${this.options.name} page contains discontinuous entries`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/** Client owner for forwarded Remote Event subscriptions and deliveries. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConnectionGenerationSource,
|
||||
ConnectionHandle,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
TypertClientEventListener,
|
||||
TypertRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import {
|
||||
REMOTE_EVENT_RESULT_ENDPOINT,
|
||||
REMOTE_EVENT_STREAM_ENDPOINT,
|
||||
REMOTE_EVENT_STREAM_PAYLOAD,
|
||||
isRemoteEventAgentId,
|
||||
isRemoteEventClientId,
|
||||
isRemoteEventId,
|
||||
isRemoteJsonValue,
|
||||
projectRemoteEventRejection,
|
||||
type RemoteEventClientId,
|
||||
type RemoteEventDownlinkFrame,
|
||||
type RemoteEventEmitFrame,
|
||||
type RemoteEventInvocationFrame,
|
||||
type RemoteEventResult,
|
||||
} from '../stream-protocol.ts'
|
||||
|
||||
/** Open the Gateway-internal forwarded-event stream on the selected carrier. */
|
||||
export type RemoteEventStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => AsyncIterable<unknown>
|
||||
|
||||
/** One subscribed listener after its event-specific signature is erased. */
|
||||
type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown
|
||||
|
||||
/** Untyped access used only for instance-private Cordis event keys. */
|
||||
interface PrivateEventContext {
|
||||
on(name: string, listener: RemoteEventListener): () => boolean
|
||||
parallel(name: string, ...args: unknown[]): Promise<void>
|
||||
waterfall(
|
||||
thisArg: Context,
|
||||
name: string,
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
next: () => Promise<symbol>,
|
||||
): unknown
|
||||
}
|
||||
|
||||
/** Transport outcome after one Client listener chain either claims or delegates. */
|
||||
type RemoteEventReplyOutcome =
|
||||
| { readonly kind: 'result'; readonly value: unknown }
|
||||
| { readonly kind: 'next' }
|
||||
| { readonly kind: 'rejected'; readonly error: ReturnType<typeof projectRemoteEventRejection> }
|
||||
|
||||
/** Private end-of-chain marker that cannot collide with a JSON listener result. */
|
||||
const REMOTE_EVENT_NEXT = Symbol('api-gateway.remote-event.next')
|
||||
|
||||
/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */
|
||||
export class ClientRemoteEvents {
|
||||
private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/`
|
||||
private readonly unregisterGeneration: () => void
|
||||
private activeGeneration: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* @param ownerCtx - Client Gateway root used for Agent Context resolution.
|
||||
* @param connection - Connection carrier used for HTTP result calls.
|
||||
* @param openStream - selected in-process or WebSocket stream opener.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ownerCtx: Context,
|
||||
private readonly connection: ConnectionHandle,
|
||||
private readonly openStream: RemoteEventStreamOpener,
|
||||
) {
|
||||
this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one typed Remote Event listener in its calling fiber.
|
||||
* @param callerCtx - fiber Context owning the registration.
|
||||
* @param event - selected forwarded event.
|
||||
* @param listener - listener derived from that event's declaration.
|
||||
* @returns disposer for this exact registration.
|
||||
*/
|
||||
subscribe<Event extends TypertRemoteEvent>(
|
||||
callerCtx: Context,
|
||||
event: Event,
|
||||
listener: TypertClientEventListener<Event>,
|
||||
): () => void {
|
||||
const dispose = privateEvents(callerCtx).on(
|
||||
this.eventKey(event),
|
||||
listener as unknown as RemoteEventListener,
|
||||
)
|
||||
return () => { dispose() }
|
||||
}
|
||||
|
||||
/** Withdraw the generation source and wait for active listener work to quiesce. */
|
||||
async dispose(): Promise<void> {
|
||||
this.unregisterGeneration()
|
||||
await Promise.allSettled([this.activeGeneration])
|
||||
}
|
||||
|
||||
/** Track the current generation so plugin disposal waits for listener work to stop. */
|
||||
private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => {
|
||||
const tracked = this.pumpEvents(signal, ready).finally(() => {
|
||||
if (this.activeGeneration === tracked) this.activeGeneration = undefined
|
||||
})
|
||||
this.activeGeneration = tracked
|
||||
return tracked
|
||||
}
|
||||
|
||||
/** Deliver one notification through Cordis while containing listener failures. */
|
||||
private deliver(frame: RemoteEventEmitFrame): void {
|
||||
void privateEvents(this.ownerCtx)
|
||||
.parallel(this.eventKey(frame.event), ...frame.args)
|
||||
.catch((error: unknown) => { this.reportError(frame.event, error) })
|
||||
}
|
||||
|
||||
/** Run one Connection generation over the forwarded-event logical stream. */
|
||||
private async pumpEvents(signal: AbortSignal, ready: () => void): Promise<void> {
|
||||
let clientId: RemoteEventClientId | undefined
|
||||
const failed = new AbortController()
|
||||
const generationSignal = AbortSignal.any([signal, failed.signal])
|
||||
const active = new Map<string, AbortController>()
|
||||
const tasks = new Set<Promise<void>>()
|
||||
const source = this.openStream(
|
||||
REMOTE_EVENT_STREAM_ENDPOINT,
|
||||
REMOTE_EVENT_STREAM_PAYLOAD,
|
||||
generationSignal,
|
||||
)
|
||||
let streamFailed = false
|
||||
let streamError: unknown
|
||||
try {
|
||||
for await (const value of source) {
|
||||
if (clientId === undefined) {
|
||||
clientId = parseRemoteEventReady(value)
|
||||
ready()
|
||||
continue
|
||||
}
|
||||
const frame = parseRemoteEventFrame(value)
|
||||
if (frame.type === 'cancel') {
|
||||
active.get(frame.eventId)?.abort(new Error('client api: Remote event was cancelled by the Host'))
|
||||
continue
|
||||
}
|
||||
if (frame.type === 'emit') {
|
||||
this.deliver(frame)
|
||||
continue
|
||||
}
|
||||
const controller = new AbortController()
|
||||
active.set(frame.eventId, controller)
|
||||
const deliverySignal = AbortSignal.any([generationSignal, controller.signal])
|
||||
const task = this.answer(frame, clientId, deliverySignal)
|
||||
.catch((error: unknown) => {
|
||||
if (!deliverySignal.aborted) failed.abort(error)
|
||||
})
|
||||
.finally(() => {
|
||||
active.delete(frame.eventId)
|
||||
tasks.delete(task)
|
||||
})
|
||||
tasks.add(task)
|
||||
}
|
||||
} catch (error) {
|
||||
streamFailed = true
|
||||
streamError = error
|
||||
} finally {
|
||||
for (const controller of active.values()) {
|
||||
controller.abort(new Error('client api: Remote event generation ended'))
|
||||
}
|
||||
await Promise.allSettled(tasks)
|
||||
}
|
||||
if (failed.signal.aborted) {
|
||||
throw toError(failed.signal.reason, 'client api: Remote event result delivery failed')
|
||||
}
|
||||
if (signal.aborted) return
|
||||
if (streamFailed) throw streamError
|
||||
throw new Error('client api: forwarded Remote event stream ended unexpectedly')
|
||||
}
|
||||
|
||||
private async answer(
|
||||
frame: RemoteEventInvocationFrame,
|
||||
clientId: RemoteEventClientId,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const adapter = this.ownerCtx.typert.contexts.getClient('agent')
|
||||
let target: Context | undefined
|
||||
try {
|
||||
target = adapter?.resolve(frame.agentId)
|
||||
} catch (error) {
|
||||
this.reportError(frame.event, error)
|
||||
}
|
||||
let outcome: RemoteEventReplyOutcome = { kind: 'next' }
|
||||
if (target !== undefined) {
|
||||
try {
|
||||
outcome = await this.dispatchWaterfall(target, frame, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) }
|
||||
}
|
||||
}
|
||||
if (signal.aborted) return
|
||||
const result: RemoteEventResult = {
|
||||
clientId,
|
||||
eventId: frame.eventId,
|
||||
outcome: outcome.kind === 'result' && outcome.value === undefined
|
||||
? { kind: 'result' }
|
||||
: outcome,
|
||||
}
|
||||
const response = await this.connection.rpc.call(
|
||||
'/api',
|
||||
REMOTE_EVENT_RESULT_ENDPOINT,
|
||||
{ args: result },
|
||||
signal,
|
||||
)
|
||||
if (!response.ok) throw new Error(response.error.message)
|
||||
}
|
||||
|
||||
private async dispatchWaterfall(
|
||||
target: Context,
|
||||
frame: RemoteEventInvocationFrame,
|
||||
signal: AbortSignal,
|
||||
): Promise<RemoteEventReplyOutcome> {
|
||||
const request = {
|
||||
...frame.request,
|
||||
agent: target,
|
||||
signal,
|
||||
}
|
||||
const value = await abortable(
|
||||
Promise.resolve(privateEvents(target).waterfall(
|
||||
target,
|
||||
this.eventKey(frame.event),
|
||||
request,
|
||||
() => Promise.resolve(REMOTE_EVENT_NEXT),
|
||||
)),
|
||||
signal,
|
||||
)
|
||||
if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) {
|
||||
throw new TypeError('Remote event listener result is not lossless JSON data')
|
||||
}
|
||||
return value === REMOTE_EVENT_NEXT
|
||||
? { kind: 'next' }
|
||||
: { kind: 'result', value }
|
||||
}
|
||||
|
||||
private eventKey(event: string): string {
|
||||
return `${this.eventPrefix}${event}`
|
||||
}
|
||||
|
||||
private reportError(event: string, error: unknown): void {
|
||||
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and return the Client identity from one generation's opening item. */
|
||||
function parseRemoteEventReady(value: unknown): RemoteEventClientId {
|
||||
if (!isRemoteEventRecord(value)
|
||||
|| !hasExactRemoteEventKeys(value, ['type', 'clientId'])
|
||||
|| value.type !== 'ready'
|
||||
|| !isRemoteEventClientId(value.clientId)) {
|
||||
throw new TypeError('client api: forwarded Remote event stream did not begin with ready')
|
||||
}
|
||||
return value.clientId
|
||||
}
|
||||
|
||||
/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */
|
||||
function parseRemoteEventFrame(value: unknown): Exclude<RemoteEventDownlinkFrame, { type: 'ready' }> {
|
||||
if (!isRemoteEventRecord(value)) invalidRemoteEventFrame()
|
||||
if (value.type === 'cancel'
|
||||
&& hasExactRemoteEventKeys(value, ['type', 'eventId'])
|
||||
&& isRemoteEventId(value.eventId)) {
|
||||
return { type: 'cancel', eventId: value.eventId }
|
||||
}
|
||||
if (value.type === 'emit'
|
||||
&& hasExactRemoteEventKeys(value, ['type', 'event', 'args'])
|
||||
&& validRemoteEventName(value.event)
|
||||
&& Array.isArray(value.args)
|
||||
&& isRemoteJsonValue(value.args)) {
|
||||
return { type: 'emit', event: value.event, args: value.args }
|
||||
}
|
||||
if (value.type === 'waterfall'
|
||||
&& hasExactRemoteEventKeys(value, ['type', 'event', 'eventId', 'agentId', 'request'])
|
||||
&& validRemoteEventName(value.event)
|
||||
&& isRemoteEventId(value.eventId)
|
||||
&& isRemoteEventAgentId(value.agentId)
|
||||
&& isRemoteEventRecord(value.request)
|
||||
&& !Object.hasOwn(value.request, 'agent')
|
||||
&& !Object.hasOwn(value.request, 'signal')
|
||||
&& isRemoteJsonValue(value.request)) {
|
||||
return {
|
||||
type: 'waterfall',
|
||||
event: value.event,
|
||||
eventId: value.eventId,
|
||||
agentId: value.agentId,
|
||||
request: value.request,
|
||||
}
|
||||
}
|
||||
invalidRemoteEventFrame()
|
||||
}
|
||||
|
||||
function isRemoteEventRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function hasExactRemoteEventKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
const ownKeys = Reflect.ownKeys(value)
|
||||
return ownKeys.length === keys.length && keys.every(key => Object.hasOwn(value, key))
|
||||
}
|
||||
|
||||
function validRemoteEventName(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function invalidRemoteEventFrame(): never {
|
||||
throw new TypeError('client api: invalid forwarded Remote event frame')
|
||||
}
|
||||
|
||||
/** Race listener completion against its delivery lifetime. */
|
||||
async function abortable<T>(value: T | PromiseLike<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
let rejectAbort: ((reason: unknown) => void) | undefined
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
const onAbort = (): void => { rejectAbort?.(signal.reason) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
return await Promise.race([Promise.resolve(value), aborted])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
function privateEvents(ctx: Context): PrivateEventContext {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function toError(reason: unknown, message: string): Error {
|
||||
return reason instanceof Error ? reason : new Error(message, { cause: reason })
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/** Reconnecting lifecycle for one single-consumer Remote stream. */
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RemoteStreamCarrierError } from './stream-client.ts'
|
||||
|
||||
/** One item annotated with the physical Remote-stream generation that delivered it. */
|
||||
export interface RemoteStreamItem<Item> {
|
||||
/** Monotone physical generation number within this logical stream. */
|
||||
readonly generation: number
|
||||
/** Decoded item yielded by the generated Remote method. */
|
||||
readonly value: Item
|
||||
/** Cancellation lifetime of the generation that delivered this item. */
|
||||
readonly signal: AbortSignal
|
||||
/** Mark this generation's opening baseline or cursor as accepted. */
|
||||
accept(): void
|
||||
}
|
||||
|
||||
/** Domain-owned operations used by {@link RemoteStream}. */
|
||||
export interface RemoteStreamOptions<Item> {
|
||||
/** Diagnostic owner name used for cancellation failures. */
|
||||
readonly name: string
|
||||
/** Open one physical generation of the logical stream. */
|
||||
readonly open: (signal: AbortSignal) => AsyncIterable<Item>
|
||||
/** Classify a normal generation end after or before its opening item was accepted. */
|
||||
readonly ended: (accepted: boolean) => Error
|
||||
/** Observe a retryable carrier loss before the supervisor waits or reopens. */
|
||||
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens one logical Remote stream across carrier generations.
|
||||
*
|
||||
* The Gateway owns physical retry timing, cancellation, and replacement. The
|
||||
* domain consumer owns its opening item and every later item, and calls
|
||||
* {@link RemoteStreamItem.accept} only after validating the opening
|
||||
* baseline or cursor.
|
||||
*/
|
||||
export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>> {
|
||||
private readonly lifetime = new AbortController()
|
||||
private generationAbort: AbortController | undefined
|
||||
private iterator: AsyncGenerator<RemoteStreamItem<Item>> | undefined
|
||||
private closing: Promise<void> | undefined
|
||||
private revision = 0
|
||||
private taken = false
|
||||
|
||||
/**
|
||||
* @param connection - observable Host generation source used to pace retries.
|
||||
* @param options - domain stream opener, end classification, and diagnostics.
|
||||
*/
|
||||
constructor(
|
||||
private readonly connection: Pick<ConnectionHandle, 'hostDescription'>,
|
||||
private readonly options: RemoteStreamOptions<Item>,
|
||||
) {}
|
||||
|
||||
/** Cancellation lifetime shared by the stream and sibling page requests. */
|
||||
get signal(): AbortSignal {
|
||||
return this.lifetime.signal
|
||||
}
|
||||
|
||||
/** Interrupt the current generation and immediately request a replacement. */
|
||||
restart(): void {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
this.revision++
|
||||
this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently stop this stream and wait for its iterator to close.
|
||||
* @returns when the active generation and consumer iterator are quiescent.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
if (this.closing !== undefined) return this.closing
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
const reason = new Error(`${this.options.name} disposed`)
|
||||
this.lifetime.abort(reason)
|
||||
this.generationAbort?.abort(reason)
|
||||
}
|
||||
const iterator = this.iterator
|
||||
if (iterator === undefined) return Promise.resolve()
|
||||
const closing = closeRemoteStreamIterator(iterator)
|
||||
this.closing = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
[Symbol.asyncIterator](): AsyncIterator<RemoteStreamItem<Item>> {
|
||||
if (this.taken) throw new Error(`${this.options.name} already has a consumer`)
|
||||
this.taken = true
|
||||
const iterator = this.read()
|
||||
this.iterator = iterator
|
||||
return iterator
|
||||
}
|
||||
|
||||
private async * read(): AsyncGenerator<RemoteStreamItem<Item>> {
|
||||
let attempt = 0
|
||||
let generation = 0
|
||||
let observedRevision = this.revision
|
||||
try {
|
||||
while (!isAborted(this.lifetime.signal)) {
|
||||
if (observedRevision !== this.revision) {
|
||||
observedRevision = this.revision
|
||||
attempt = 0
|
||||
}
|
||||
const revision = this.revision
|
||||
const generationAbort = new AbortController()
|
||||
this.generationAbort = generationAbort
|
||||
const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal])
|
||||
const generationId = ++generation
|
||||
let accepted = false
|
||||
try {
|
||||
for await (const value of this.options.open(signal)) {
|
||||
if (isAborted(this.lifetime.signal)) return
|
||||
if (revision !== this.revision) break
|
||||
yield {
|
||||
generation: generationId,
|
||||
value,
|
||||
signal,
|
||||
accept: () => {
|
||||
if (this.generationAbort !== generationAbort || revision !== this.revision) return
|
||||
accepted = true
|
||||
attempt = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
if (isAborted(this.lifetime.signal)) return
|
||||
if (revision !== this.revision) continue
|
||||
throw this.options.ended(accepted)
|
||||
} catch (error) {
|
||||
if (isAborted(this.lifetime.signal)) return
|
||||
if (revision !== this.revision) continue
|
||||
if (!(error instanceof RemoteStreamCarrierError)) throw error
|
||||
this.options.carrierFailed?.(error)
|
||||
if (revision !== this.revision) continue
|
||||
attempt++
|
||||
try {
|
||||
await waitForRemoteStreamRetry(this.connection, error, attempt, signal)
|
||||
} catch (retryError) {
|
||||
if (isAborted(this.lifetime.signal)) return
|
||||
if (revision !== this.revision) continue
|
||||
throw retryError
|
||||
}
|
||||
} finally {
|
||||
this.generationAbort = undefined
|
||||
if (!generationAbort.signal.aborted) {
|
||||
generationAbort.abort(new Error(`${this.options.name} generation ended`))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
this.lifetime.abort(new Error(`${this.options.name} consumer closed`))
|
||||
}
|
||||
this.generationAbort?.abort(this.lifetime.signal.reason)
|
||||
this.generationAbort = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRemoteStreamRetry(
|
||||
connection: Pick<ConnectionHandle, 'hostDescription'>,
|
||||
error: RemoteStreamCarrierError,
|
||||
attempt: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (connection.hostDescription.getSnapshot() !== undefined) {
|
||||
if (attempt === 1) return
|
||||
throw error
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const subscription: {
|
||||
dispose?: () => void
|
||||
finished: boolean
|
||||
} = { finished: false }
|
||||
const finish = (failure?: Error): void => {
|
||||
if (subscription.finished) return
|
||||
subscription.finished = true
|
||||
subscription.dispose?.()
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (failure === undefined) resolve()
|
||||
else reject(failure)
|
||||
}
|
||||
const inspect = (): void => {
|
||||
if (connection.hostDescription.getSnapshot() !== undefined) finish()
|
||||
}
|
||||
const aborted = (): void => {
|
||||
finish(new Error('Remote stream retry aborted', { cause: signal.reason }))
|
||||
}
|
||||
const dispose = connection.hostDescription.subscribe(inspect)
|
||||
subscription.dispose = dispose
|
||||
if (subscription.finished) dispose()
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
if (signal.aborted) aborted()
|
||||
else inspect()
|
||||
})
|
||||
}
|
||||
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
async function closeRemoteStreamIterator<Item>(
|
||||
iterator: AsyncIterator<RemoteStreamItem<Item>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await iterator.return?.()
|
||||
} catch {
|
||||
// The disposed logical stream has no remaining consumer for cancellation failures.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */
|
||||
|
||||
import type { RemoteStream } from './remote-stream.ts'
|
||||
|
||||
/** Domain operations for one snapshot stream. */
|
||||
export interface RemoteSnapshotStreamOptions<Snapshot, Delta> {
|
||||
/** Diagnostic stream name used in protocol failures. */
|
||||
readonly name: string
|
||||
/** Distinguish the opening snapshot from later deltas. */
|
||||
readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot
|
||||
/** Atomically replace the domain model from a complete snapshot. */
|
||||
readonly replace: (snapshot: Snapshot) => void
|
||||
/** Apply one incremental update after the generation snapshot. */
|
||||
readonly update: (delta: Delta) => void
|
||||
/** Publish a terminal business or protocol failure. */
|
||||
readonly failed: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes generations that each contain exactly one opening snapshot followed by deltas.
|
||||
*
|
||||
* The previous domain snapshot remains published while the underlying stream retries. A
|
||||
* replacement becomes accepted only after the domain owner applies it successfully.
|
||||
*/
|
||||
export class RemoteSnapshotStream<Snapshot, Delta> {
|
||||
private started = false
|
||||
private disposed = false
|
||||
private done: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* @param stream - reconnecting physical-generation stream.
|
||||
* @param options - frame discriminator and domain state destinations.
|
||||
*/
|
||||
constructor(
|
||||
private readonly stream: RemoteStream<Snapshot | Delta>,
|
||||
private readonly options: RemoteSnapshotStreamOptions<Snapshot, Delta>,
|
||||
) {}
|
||||
|
||||
/** Start the single consumer; repeated calls are inert. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.done = this.consume()
|
||||
}
|
||||
|
||||
/** Replace the active physical generation without discarding the published snapshot. */
|
||||
restart(): void {
|
||||
this.stream.restart()
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently stop the stream and wait for its consumer to become quiescent.
|
||||
* @returns when no generation or callback can still run.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
await this.stream.dispose()
|
||||
await this.done
|
||||
}
|
||||
|
||||
private async consume(): Promise<void> {
|
||||
let generation = 0
|
||||
let snapshotSeen = false
|
||||
try {
|
||||
for await (const item of this.stream) {
|
||||
if (item.generation !== generation) {
|
||||
generation = item.generation
|
||||
snapshotSeen = false
|
||||
}
|
||||
if (this.options.isSnapshot(item.value)) {
|
||||
if (snapshotSeen) {
|
||||
throw new Error(`${this.options.name} emitted more than one opening snapshot`)
|
||||
}
|
||||
this.options.replace(item.value)
|
||||
snapshotSeen = true
|
||||
item.accept()
|
||||
continue
|
||||
}
|
||||
if (!snapshotSeen) {
|
||||
throw new Error(`${this.options.name} emitted an update before its opening snapshot`)
|
||||
}
|
||||
this.options.update(item.value)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.disposed) this.options.failed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/** Browser owner for the Gateway multiplexed Remote stream socket. */
|
||||
|
||||
import {
|
||||
parseRemoteStreamServerMessage,
|
||||
REMOTE_STREAM_MUX_PATH,
|
||||
type RemoteStreamClientMessage,
|
||||
type RemoteStreamServerMessage,
|
||||
} from '../stream-protocol.ts'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const RECONNECT_BASE_MS = 500
|
||||
const RECONNECT_FACTOR = 2
|
||||
const RECONNECT_MAX_MS = 10_000
|
||||
|
||||
/** One Host-reported Remote stream failure. */
|
||||
export class RemoteStreamError extends Error {
|
||||
/** Stable carrier or Gateway error category. */
|
||||
readonly code: string
|
||||
/** Host-provided structured failure context. */
|
||||
readonly details: object
|
||||
|
||||
/**
|
||||
* @param code - stable Gateway or business error category.
|
||||
* @param message - Host-provided failure description.
|
||||
* @param details - Host-provided structured failure context.
|
||||
*/
|
||||
constructor(code: string, message: string, details: object) {
|
||||
super(message)
|
||||
this.name = 'RemoteStreamError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/** Physical Remote stream socket failure that may be retried by a domain transport. */
|
||||
export class RemoteStreamCarrierError extends Error {
|
||||
/**
|
||||
* @param message - physical carrier failure description.
|
||||
* @param options - optional causal error.
|
||||
*/
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'RemoteStreamCarrierError'
|
||||
}
|
||||
}
|
||||
|
||||
interface SocketWaiter {
|
||||
resolve(socket: WebSocket): void
|
||||
reject(error: unknown): void
|
||||
}
|
||||
|
||||
/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */
|
||||
export class RemoteStreamMuxClient {
|
||||
private socket: WebSocket | undefined
|
||||
private cancelCandidate: ((error: Error) => void) | undefined
|
||||
private keepAlive: Promise<void> | undefined
|
||||
private keepAliveAbort: AbortController | undefined
|
||||
private readonly streams = new Map<string, StreamInbox>()
|
||||
private readonly waiters = new Set<SocketWaiter>()
|
||||
private running = false
|
||||
private disposed = false
|
||||
|
||||
/** Start the persistent physical connection; repeated calls are inert. */
|
||||
start(): void {
|
||||
if (this.running || this.disposed) return
|
||||
this.running = true
|
||||
this.maintain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one logical stream on the persistent physical connection.
|
||||
* @param endpoint - Typert Remote stream endpoint.
|
||||
* @param payload - endpoint request encoded on the wire.
|
||||
* @param signal - cancellation for this logical stream.
|
||||
* @returns Host items until completion, cancellation, or failure.
|
||||
*/
|
||||
async *open(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator {
|
||||
this.start()
|
||||
signal.throwIfAborted()
|
||||
const streamId = randomUUID()
|
||||
const inbox = new StreamInbox()
|
||||
let carrier: WebSocket | undefined
|
||||
let opened = false
|
||||
let terminal = false
|
||||
const abort = (): void => { inbox.fail(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try {
|
||||
const socket = await this.waitForSocket(signal)
|
||||
signal.throwIfAborted()
|
||||
carrier = socket
|
||||
this.streams.set(streamId, inbox)
|
||||
this.send(socket, { type: 'open', streamId, endpoint, payload })
|
||||
opened = true
|
||||
while (true) {
|
||||
const frame = await inbox.next()
|
||||
signal.throwIfAborted()
|
||||
if (frame.type === 'item') {
|
||||
yield frame.value
|
||||
continue
|
||||
}
|
||||
terminal = true
|
||||
if (frame.type === 'error') {
|
||||
throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details)
|
||||
}
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
this.streams.delete(streamId)
|
||||
if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) {
|
||||
this.send(carrier, { type: 'cancel', streamId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently stop reconnecting, close the physical socket, and fail every active logical stream.
|
||||
* @returns once the background connection loop has stopped.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.disposed) {
|
||||
this.disposed = true
|
||||
this.running = false
|
||||
const error = new Error('api gateway: Remote stream client disposed')
|
||||
this.keepAliveAbort?.abort(error)
|
||||
this.keepAliveAbort = undefined
|
||||
this.failAll(error)
|
||||
for (const waiter of [...this.waiters]) waiter.reject(error)
|
||||
this.cancelCandidate?.(error)
|
||||
const socket = this.socket
|
||||
this.socket = undefined
|
||||
socket?.close(1000, 'disposed')
|
||||
}
|
||||
await this.keepAlive
|
||||
}
|
||||
|
||||
private connect(): Promise<WebSocket> {
|
||||
const socket = new WebSocket(remoteStreamUrl())
|
||||
const connecting = new Promise<WebSocket>((resolve, reject) => {
|
||||
let settled = false
|
||||
const rejectCandidate = (error: Error): void => {
|
||||
settled = true
|
||||
socket.removeEventListener('open', opened)
|
||||
socket.removeEventListener('error', failed)
|
||||
socket.removeEventListener('message', received)
|
||||
socket.removeEventListener('close', closed)
|
||||
this.cancelCandidate = undefined
|
||||
socket.close()
|
||||
reject(error)
|
||||
}
|
||||
const opened = (): void => {
|
||||
settled = true
|
||||
this.cancelCandidate = undefined
|
||||
this.socket = socket
|
||||
for (const waiter of [...this.waiters]) waiter.resolve(socket)
|
||||
resolve(socket)
|
||||
}
|
||||
const failed = (): void => {
|
||||
if (!settled) {
|
||||
rejectCandidate(new RemoteStreamCarrierError(
|
||||
'api gateway: Remote stream WebSocket failed to open',
|
||||
))
|
||||
return
|
||||
}
|
||||
const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed')
|
||||
this.lost(socket, error)
|
||||
socket.close()
|
||||
}
|
||||
const closed = (): void => {
|
||||
if (!settled) {
|
||||
rejectCandidate(new RemoteStreamCarrierError(
|
||||
'api gateway: Remote stream WebSocket closed before opening',
|
||||
))
|
||||
return
|
||||
}
|
||||
this.lost(socket)
|
||||
}
|
||||
const received = (event: MessageEvent): void => { this.receive(socket, event.data) }
|
||||
this.cancelCandidate = rejectCandidate
|
||||
socket.addEventListener('open', opened, { once: true })
|
||||
socket.addEventListener('error', failed, { once: true })
|
||||
socket.addEventListener('message', received)
|
||||
socket.addEventListener('close', closed, { once: true })
|
||||
})
|
||||
return connecting
|
||||
}
|
||||
|
||||
private waitForSocket(signal: AbortSignal): Promise<WebSocket> {
|
||||
signal.throwIfAborted()
|
||||
if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket)
|
||||
if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed'))
|
||||
this.start()
|
||||
return new Promise((resolve, reject) => {
|
||||
const aborted = (): void => { waiter.reject(signal.reason) }
|
||||
const cleanup = (): void => {
|
||||
this.waiters.delete(waiter)
|
||||
signal.removeEventListener('abort', aborted)
|
||||
}
|
||||
const waiter: SocketWaiter = {
|
||||
resolve: (socket) => {
|
||||
cleanup()
|
||||
resolve(socket)
|
||||
},
|
||||
reject: (error) => {
|
||||
cleanup()
|
||||
// AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
this.waiters.add(waiter)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
private receive(socket: WebSocket, data: unknown): void {
|
||||
if (socket !== this.socket) return
|
||||
try {
|
||||
if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages')
|
||||
const frame = parseRemoteStreamServerMessage(data)
|
||||
this.streams.get(frame.streamId)?.push(frame)
|
||||
} catch (error) {
|
||||
const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error })
|
||||
this.failAll(failure)
|
||||
this.lost(socket, failure)
|
||||
socket.close(4002, 'invalid Remote stream frame')
|
||||
}
|
||||
}
|
||||
|
||||
private lost(
|
||||
socket: WebSocket,
|
||||
error: RemoteStreamCarrierError = new RemoteStreamCarrierError(
|
||||
'api gateway: Remote stream WebSocket closed',
|
||||
),
|
||||
): void {
|
||||
if (this.socket !== socket) return
|
||||
this.socket = undefined
|
||||
this.failAll(error)
|
||||
this.maintain(error)
|
||||
}
|
||||
|
||||
private maintain(previousFailure?: Error): void {
|
||||
if (!this.running) return
|
||||
if (this.keepAlive !== undefined) {
|
||||
void this.keepAlive.then(() => { this.maintain(previousFailure) })
|
||||
return
|
||||
}
|
||||
const abort = new AbortController()
|
||||
this.keepAliveAbort = abort
|
||||
const task = this.reconnect(abort.signal, previousFailure)
|
||||
this.keepAlive = task
|
||||
void task.then(() => {
|
||||
this.keepAlive = undefined
|
||||
this.keepAliveAbort = undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise<void> {
|
||||
let attempt = 0
|
||||
let failure = previousFailure
|
||||
while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) {
|
||||
if (failure !== undefined) {
|
||||
attempt += 1
|
||||
console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure)
|
||||
await sleep(backoffDelay(attempt), signal)
|
||||
if (!this.isRunning(signal)) return
|
||||
}
|
||||
try {
|
||||
await this.connect()
|
||||
return
|
||||
} catch (error) {
|
||||
if (!this.isRunning(signal)) return
|
||||
failure = error as Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isRunning(signal: AbortSignal): boolean {
|
||||
return this.running && !signal.aborted
|
||||
}
|
||||
|
||||
private failAll(error: unknown): void {
|
||||
for (const stream of this.streams.values()) stream.fail(error)
|
||||
}
|
||||
|
||||
private send(socket: WebSocket, message: RemoteStreamClientMessage): void {
|
||||
socket.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
function backoffDelay(attempt: number): number {
|
||||
const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1))
|
||||
return cap / 2 + Math.random() * (cap / 2)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms)
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
class StreamInbox {
|
||||
private readonly frames: RemoteStreamServerMessage[] = []
|
||||
private wake: (() => void) | undefined
|
||||
private failure: Error | undefined
|
||||
|
||||
push(frame: RemoteStreamServerMessage): void {
|
||||
if (this.failure !== undefined) return
|
||||
this.frames.push(frame)
|
||||
this.wake?.()
|
||||
this.wake = undefined
|
||||
}
|
||||
|
||||
fail(error: unknown): void {
|
||||
if (this.failure !== undefined) return
|
||||
this.failure = error instanceof Error ? error : new Error(String(error), { cause: error })
|
||||
this.frames.length = 0
|
||||
this.wake?.()
|
||||
this.wake = undefined
|
||||
}
|
||||
|
||||
async next(): Promise<RemoteStreamServerMessage> {
|
||||
while (this.frames.length === 0) {
|
||||
if (this.failure !== undefined) throw this.failure
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
return this.frames.shift() as RemoteStreamServerMessage
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStreamUrl(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, base)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return url.href
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Live Typert Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* Unary transport and response envelopes belong to Connection; live Remote
|
||||
* streams use the Gateway-owned WebSocket mux.
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypertLookupFailure,
|
||||
TypertRemoteFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypertCodec,
|
||||
@@ -18,12 +22,47 @@ import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
TypertGatewayWireStream,
|
||||
TypertRemoteEventDispatch,
|
||||
TypertRemoteEventFrame,
|
||||
TypertRemoteEventInvocation,
|
||||
TypertRemoteEventOutcome,
|
||||
TypertRemoteEventSource,
|
||||
} from './types.ts'
|
||||
import {
|
||||
RemoteStreamMuxServer,
|
||||
rejectRemoteStreamUpgrade,
|
||||
} from './stream-server.ts'
|
||||
import {
|
||||
REMOTE_EVENT_STREAM_ENDPOINT,
|
||||
REMOTE_EVENT_STREAM_READY,
|
||||
REMOTE_EVENT_RESULT_ENDPOINT,
|
||||
REMOTE_STREAM_MUX_PATH,
|
||||
isRemoteEventAgentId,
|
||||
isRemoteJsonValue,
|
||||
parseRemoteEventResult,
|
||||
projectRemoteEventRequest,
|
||||
restoreRemoteEventRejection,
|
||||
type RemoteEventCancellationFrame,
|
||||
type RemoteEventClientId,
|
||||
type RemoteEventEmitFrame,
|
||||
type RemoteEventId,
|
||||
type RemoteEventInvocationFrame,
|
||||
type RemoteEventReadyFrame,
|
||||
type RemoteStreamFailure,
|
||||
} from './stream-protocol.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
TypertGatewayWireStream,
|
||||
TypertRemoteEventContext,
|
||||
TypertRemoteEventDispatch,
|
||||
TypertRemoteEventFrame,
|
||||
TypertRemoteEventInvocation,
|
||||
TypertRemoteEventOutcome,
|
||||
TypertRemoteEventSource,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
@@ -36,6 +75,34 @@ interface ResolvedBinding {
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
interface PreparedInvocation {
|
||||
readonly endpoint: string
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly receiver: object
|
||||
readonly args: readonly unknown[]
|
||||
readonly method: (...args: never[]) => unknown
|
||||
}
|
||||
|
||||
interface RegisteredRemoteEventSource {
|
||||
readonly lifetime: AbortController
|
||||
readonly done: Promise<void>
|
||||
}
|
||||
|
||||
interface RemoteEventClient {
|
||||
readonly id: RemoteEventClientId
|
||||
readonly queue: RemoteEventQueue
|
||||
readonly deliveries: Map<RemoteEventId, PendingRemoteEvent>
|
||||
}
|
||||
|
||||
interface PendingRemoteEvent {
|
||||
readonly id: RemoteEventId
|
||||
readonly source: TypertRemoteEventInvocation
|
||||
readonly frame: RemoteEventInvocationFrame
|
||||
readonly deliveries: Set<RemoteEventClient>
|
||||
releaseContext: () => void
|
||||
releaseSignal: () => void
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
@@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error {
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
/** Carrier adapter shared by the WebSocket mux and local Host transports. */
|
||||
readonly wireStream: TypertGatewayWireStream = {
|
||||
open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
|
||||
failure: error => rpcError(error),
|
||||
}
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
private remoteEvents: RegisteredRemoteEventSource | undefined
|
||||
private readonly remoteEventClients = new Map<RemoteEventClientId, RemoteEventClient>()
|
||||
private readonly pendingRemoteEvents = new Map<RemoteEventId, PendingRemoteEvent>()
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active Typert registry.
|
||||
@@ -109,9 +185,63 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
})
|
||||
ctx.inject(['connection', 'webServer'], (webCtx) => {
|
||||
const mux = new RemoteStreamMuxServer(
|
||||
(endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal),
|
||||
this.wireStream.failure,
|
||||
)
|
||||
webCtx.effect(() => {
|
||||
const route: WebUpgradeRoute = {
|
||||
path: REMOTE_STREAM_MUX_PATH,
|
||||
handler: (req, socket, head) => {
|
||||
if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) {
|
||||
rejectRemoteStreamUpgrade(socket)
|
||||
return
|
||||
}
|
||||
mux.handleUpgrade(req, socket, head)
|
||||
},
|
||||
}
|
||||
const unregister = webCtx.webServer.registerUpgrade(route)
|
||||
return async () => {
|
||||
unregister()
|
||||
await mux.close()
|
||||
}
|
||||
}, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the sole application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
* @returns disposer removing this source and cancelling its active streams.
|
||||
*/
|
||||
registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void> {
|
||||
if (this.remoteEvents !== undefined) {
|
||||
throw new Error('typert gateway: forwarded Remote event source is already registered')
|
||||
}
|
||||
const lifetime = new AbortController()
|
||||
const stream = source(lifetime.signal)
|
||||
const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => {
|
||||
if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return
|
||||
this.closeRemoteEvents(error)
|
||||
this.remoteEvents = undefined
|
||||
lifetime.abort(error)
|
||||
})
|
||||
const registration: RegisteredRemoteEventSource = { lifetime, done }
|
||||
this.remoteEvents = registration
|
||||
return async () => {
|
||||
if (this.remoteEvents === registration) {
|
||||
this.remoteEvents = undefined
|
||||
const error = new Error('typert gateway: forwarded Remote event source was removed')
|
||||
registration.lifetime.abort(error)
|
||||
this.closeRemoteEvents(error)
|
||||
}
|
||||
await registration.done
|
||||
}
|
||||
}
|
||||
|
||||
private claimsEndpoint(endpoint: string): boolean {
|
||||
if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
|
||||
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
|
||||
@@ -143,6 +273,315 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const prepared = await this.prepareInvocation(request)
|
||||
if (prepared.descriptor.mode === 'stream') {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
prepared.endpoint,
|
||||
'stream Remote methods must be opened through the stream carrier',
|
||||
)
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
try {
|
||||
result = await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error)
|
||||
throw error
|
||||
}
|
||||
// A weak descriptor declares no return type, so nothing returned is a void
|
||||
// result and rides the wire as an absent value field. A strict descriptor
|
||||
// keeps its schema: there, undefined has to be a declared result.
|
||||
if (result === undefined && prepared.descriptor.result.mode !== 'strict') return result
|
||||
return decode(prepared.descriptor.result, result, 'result-invalid', prepared.endpoint, 'result')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns an iterable whose items have passed the generated result codec.
|
||||
*/
|
||||
async stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>> {
|
||||
const prepared = await this.prepareInvocation(request)
|
||||
if (prepared.descriptor.mode !== 'stream') {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
prepared.endpoint,
|
||||
'unary Remote methods cannot be opened through the stream carrier',
|
||||
)
|
||||
}
|
||||
let source: unknown
|
||||
try {
|
||||
source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error)
|
||||
throw error
|
||||
}
|
||||
if (!isIterable(source)) {
|
||||
throw new TypertGatewayError(
|
||||
'result-invalid',
|
||||
prepared.endpoint,
|
||||
'stream Remote method did not return Iterable or AsyncIterable',
|
||||
{ field: 'result' },
|
||||
)
|
||||
}
|
||||
return validatedStream(
|
||||
source,
|
||||
prepared.descriptor.result,
|
||||
prepared.endpoint,
|
||||
request.signal ?? NEVER_ABORTED_SIGNAL,
|
||||
)
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
|
||||
try {
|
||||
const result = parseRemoteEventResultPayload(payload)
|
||||
const client = this.remoteEventClients.get(result.clientId)
|
||||
if (client === undefined) {
|
||||
throw new Error('typert gateway: Remote event result identifies no active event stream')
|
||||
}
|
||||
this.receiveRemoteEventResult(client, result)
|
||||
return { ok: true, value: undefined }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async openWireStream(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<AsyncIterable<unknown>> {
|
||||
if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) {
|
||||
return this.openRemoteEvents(payload, signal)
|
||||
}
|
||||
return this.stream(remoteRequest(endpoint, payload, signal))
|
||||
}
|
||||
|
||||
private async *openRemoteEvents(
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<
|
||||
RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame
|
||||
| RemoteEventReadyFrame
|
||||
> {
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)
|
||||
|| Reflect.ownKeys(payload.args).length !== 0) {
|
||||
throw new TypertGatewayError(
|
||||
'arguments-invalid',
|
||||
REMOTE_EVENT_STREAM_ENDPOINT,
|
||||
'forwarded Remote event stream requires an empty args object',
|
||||
)
|
||||
}
|
||||
const registration = this.remoteEvents
|
||||
if (registration === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
REMOTE_EVENT_STREAM_ENDPOINT,
|
||||
'forwarded Remote event source is unavailable',
|
||||
)
|
||||
}
|
||||
const lifetime = AbortSignal.any([signal, registration.lifetime.signal])
|
||||
let clientId = randomUUID() as RemoteEventClientId
|
||||
while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId
|
||||
const client: RemoteEventClient = {
|
||||
id: clientId,
|
||||
queue: new RemoteEventQueue(),
|
||||
deliveries: new Map(),
|
||||
}
|
||||
this.remoteEventClients.set(clientId, client)
|
||||
for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client)
|
||||
try {
|
||||
yield { ...REMOTE_EVENT_STREAM_READY, clientId }
|
||||
yield* client.queue.iterate(lifetime)
|
||||
} finally {
|
||||
this.removeRemoteEventClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeRemoteEvents(
|
||||
source: AsyncIterable<TypertRemoteEventDispatch>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
for await (const dispatch of source) {
|
||||
if (signal.aborted) {
|
||||
if ('context' in dispatch) dispatch.reject(signal.reason)
|
||||
return
|
||||
}
|
||||
if ('context' in dispatch) this.startRemoteEvent(dispatch)
|
||||
else this.broadcastRemoteEvent(dispatch)
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
throw new Error('typert gateway: forwarded Remote event source ended unexpectedly')
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void {
|
||||
assertRemoteEventFrame(frame)
|
||||
const wire: RemoteEventEmitFrame = {
|
||||
type: 'emit',
|
||||
event: frame.event,
|
||||
args: frame.args,
|
||||
}
|
||||
for (const client of this.remoteEventClients.values()) client.queue.push(wire)
|
||||
}
|
||||
|
||||
private startRemoteEvent(source: TypertRemoteEventInvocation): void {
|
||||
try {
|
||||
assertRemoteEventName(source)
|
||||
const context = this.ctx.typert.contexts.identifyHost(source.context.value)
|
||||
if (context === undefined) {
|
||||
source.resolve({ kind: 'next' })
|
||||
return
|
||||
}
|
||||
if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) {
|
||||
throw new TypeError(
|
||||
'typert gateway: scoped Remote events require a non-empty Agent identity',
|
||||
)
|
||||
}
|
||||
const projected = projectRemoteEventRequest(source.request, source.context.subject)
|
||||
let id = randomUUID() as RemoteEventId
|
||||
while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId
|
||||
let releaseContext: () => void
|
||||
try {
|
||||
const dispose = source.context.value.effect(
|
||||
() => () => {
|
||||
this.cancelRemoteEvent(
|
||||
pending,
|
||||
new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`),
|
||||
)
|
||||
},
|
||||
`api-gateway: Remote event ${JSON.stringify(source.event)}`,
|
||||
)
|
||||
releaseContext = () => { void dispose() }
|
||||
} catch {
|
||||
source.resolve({ kind: 'next' })
|
||||
return
|
||||
}
|
||||
const signals = new Set(projected.signal === undefined ? [] : [projected.signal])
|
||||
const abort = (): void => {
|
||||
const reason = [...signals].find(signal => signal.aborted)?.reason as unknown
|
||||
this.cancelRemoteEvent(pending, reason instanceof Error
|
||||
? reason
|
||||
: new Error('typert gateway: Remote event was cancelled', { cause: reason }))
|
||||
}
|
||||
const pending: PendingRemoteEvent = {
|
||||
id,
|
||||
source,
|
||||
frame: {
|
||||
type: 'waterfall',
|
||||
event: source.event,
|
||||
eventId: id,
|
||||
agentId: context.identity,
|
||||
request: projected.request,
|
||||
},
|
||||
deliveries: new Set(),
|
||||
releaseContext,
|
||||
releaseSignal: () => {
|
||||
for (const signal of signals) signal.removeEventListener('abort', abort)
|
||||
},
|
||||
}
|
||||
this.pendingRemoteEvents.set(id, pending)
|
||||
for (const signal of signals) signal.addEventListener('abort', abort, { once: true })
|
||||
if ([...signals].some(signal => signal.aborted)) abort()
|
||||
else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client)
|
||||
} catch (error) {
|
||||
source.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void {
|
||||
pending.deliveries.add(client)
|
||||
client.deliveries.set(pending.id, pending)
|
||||
client.queue.push(pending.frame)
|
||||
}
|
||||
|
||||
private receiveRemoteEventResult(
|
||||
client: RemoteEventClient,
|
||||
result: ReturnType<typeof parseRemoteEventResult>,
|
||||
): void {
|
||||
const pending = this.pendingRemoteEvents.get(result.eventId)
|
||||
if (pending === undefined || !pending.deliveries.has(client)) return
|
||||
this.removeRemoteEventDelivery(pending, client)
|
||||
if (result.outcome.kind === 'result') {
|
||||
this.settleRemoteEvent(pending, {
|
||||
kind: 'result',
|
||||
value: result.outcome.value,
|
||||
})
|
||||
} else if (result.outcome.kind === 'rejected') {
|
||||
this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error))
|
||||
} else if (pending.deliveries.size === 0) {
|
||||
this.settleRemoteEvent(pending, { kind: 'next' })
|
||||
}
|
||||
}
|
||||
|
||||
private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void {
|
||||
pending.deliveries.delete(client)
|
||||
client.deliveries.delete(pending.id)
|
||||
}
|
||||
|
||||
private removeRemoteEventClient(client: RemoteEventClient): void {
|
||||
this.remoteEventClients.delete(client.id)
|
||||
for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client)
|
||||
client.queue.end()
|
||||
}
|
||||
|
||||
private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void {
|
||||
this.finishRemoteEvent(pending)
|
||||
pending.source.resolve(outcome)
|
||||
}
|
||||
|
||||
private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void {
|
||||
if (this.pendingRemoteEvents.get(pending.id) !== pending) return
|
||||
this.finishRemoteEvent(pending)
|
||||
pending.source.reject(reason)
|
||||
}
|
||||
|
||||
private finishRemoteEvent(pending: PendingRemoteEvent): void {
|
||||
this.pendingRemoteEvents.delete(pending.id)
|
||||
pending.releaseSignal()
|
||||
pending.releaseContext()
|
||||
const clients = new Set(pending.deliveries)
|
||||
for (const client of clients) this.removeRemoteEventDelivery(pending, client)
|
||||
const cancellation: RemoteEventCancellationFrame = {
|
||||
type: 'cancel',
|
||||
eventId: pending.id,
|
||||
}
|
||||
for (const client of clients) client.queue.push(cancellation)
|
||||
}
|
||||
|
||||
private closeRemoteEvents(reason: unknown): void {
|
||||
for (const pending of [...this.pendingRemoteEvents.values()]) {
|
||||
this.cancelRemoteEvent(pending, reason)
|
||||
}
|
||||
for (const client of [...this.remoteEventClients.values()]) client.queue.end()
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const value = await this.invoke(remoteRequest(endpoint, payload, signal))
|
||||
// A void or explicitly absent business result carries no `value` field;
|
||||
// JSON has no `undefined`, and the envelope's optional slot is the one
|
||||
// representation of absence that both args and results already use.
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareInvocation(request: InvokeRemoteRequest): Promise<PreparedInvocation> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
@@ -168,57 +607,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
try {
|
||||
result = await Reflect.apply(method, receiver, args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
|
||||
throw error
|
||||
}
|
||||
// A weak descriptor declares no return type, so nothing returned is a void
|
||||
// result and rides the wire as an absent value field. A strict descriptor
|
||||
// keeps its schema: there, undefined has to be a declared result.
|
||||
if (result === undefined && descriptor.result.mode !== 'strict') return result
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
// A void or explicitly absent business result carries no `value` field;
|
||||
// JSON has no `undefined`, and the envelope's optional slot is the one
|
||||
// representation of absence that both args and results already use.
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown }
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
@@ -349,6 +738,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
...(marker.mode === undefined ? {} : { mode: marker.mode }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
@@ -468,6 +858,121 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteEventWireFrame =
|
||||
| RemoteEventEmitFrame
|
||||
| RemoteEventInvocationFrame
|
||||
| RemoteEventCancellationFrame
|
||||
|
||||
/** Pull-driven queue owned by one connected Client event generation. */
|
||||
class RemoteEventQueue {
|
||||
private readonly frames: RemoteEventWireFrame[] = []
|
||||
private waiter: (() => void) | undefined
|
||||
private closed = false
|
||||
|
||||
push(frame: RemoteEventWireFrame): void {
|
||||
if (this.closed) return
|
||||
this.frames.push(frame)
|
||||
this.waiter?.()
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.waiter?.()
|
||||
}
|
||||
|
||||
async *iterate(signal: AbortSignal): AsyncGenerator<RemoteEventWireFrame> {
|
||||
const abort = (): void => { this.end() }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try {
|
||||
while (true) {
|
||||
while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame
|
||||
if (this.closed || signal.aborted) return
|
||||
await new Promise<void>((resolve) => { this.waiter = resolve })
|
||||
this.waiter = undefined
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void {
|
||||
assertRemoteEventName(frame)
|
||||
if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) {
|
||||
throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRemoteEventName(frame: { readonly event: unknown }): void {
|
||||
if (typeof frame.event !== 'string' || frame.event.length === 0) {
|
||||
throw new TypeError('typert gateway: Remote event name must be a nonempty string')
|
||||
}
|
||||
}
|
||||
|
||||
function parseRemoteEventResultPayload(payload: unknown): ReturnType<typeof parseRemoteEventResult> {
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')) {
|
||||
throw new Error('typert gateway: Remote event result requires exactly one plain-object args field')
|
||||
}
|
||||
return parseRemoteEventResult(payload.args)
|
||||
}
|
||||
|
||||
function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
return { namespace, method, args: payload.args, signal }
|
||||
}
|
||||
|
||||
function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<unknown> {
|
||||
return isObject(value)
|
||||
&& (typeof Reflect.get(value, Symbol.iterator) === 'function'
|
||||
|| typeof Reflect.get(value, Symbol.asyncIterator) === 'function')
|
||||
}
|
||||
|
||||
async function *validatedStream(
|
||||
source: Iterable<unknown> | AsyncIterable<unknown>,
|
||||
codec: TypertCodec,
|
||||
endpoint: string,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator {
|
||||
const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown
|
||||
const syncFactory = Reflect.get(source, Symbol.iterator) as unknown
|
||||
const iterator = typeof asyncFactory === 'function'
|
||||
? Reflect.apply(asyncFactory, source, []) as AsyncIterator<unknown>
|
||||
: Reflect.apply(syncFactory as (...args: never[]) => Iterator<unknown>, source, [])
|
||||
let rejectAbort: ((error: unknown) => void) | undefined
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
const onAbort = (): void => {
|
||||
rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason)
|
||||
while (true) {
|
||||
const next = await Promise.race([Promise.resolve(iterator.next()), aborted])
|
||||
if (next.done === true) return
|
||||
yield decode(codec, next.value, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
await iterator.return?.()
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof RemoteInvocationCancelled) {
|
||||
return {
|
||||
@@ -478,6 +983,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypertLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
if (error instanceof TypertRemoteFailure) {
|
||||
return { ok: false, error: error.failure }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
@@ -488,6 +996,10 @@ function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
}
|
||||
}
|
||||
|
||||
function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure {
|
||||
return (rpcFailure(error) as Extract<ConnectionRpcResult, { readonly ok: false }>).error
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Exact WebSocket route carrying every Typert Remote stream. */
|
||||
export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux'
|
||||
|
||||
/** Gateway-internal logical stream carrying application-selected Cordis events. */
|
||||
export const REMOTE_EVENT_STREAM_ENDPOINT = '$events'
|
||||
|
||||
/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */
|
||||
export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result'
|
||||
|
||||
/** Empty standard Remote payload used to open the forwarded-event stream. */
|
||||
export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const
|
||||
|
||||
/** Discriminator for the first item proving the Host event source is ready. */
|
||||
export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const
|
||||
|
||||
/** Opaque identity for one active Client Remote Event generation. */
|
||||
export type RemoteEventClientId = Branded<'RemoteEventClientId'>
|
||||
|
||||
/** Opaque correlation id for one pending Host-to-Client Remote Event. */
|
||||
export type RemoteEventId = Branded<'RemoteEventId'>
|
||||
|
||||
/** Opening item that binds later HTTP results to this active event stream. */
|
||||
export interface RemoteEventReadyFrame {
|
||||
readonly type: 'ready'
|
||||
readonly clientId: RemoteEventClientId
|
||||
}
|
||||
|
||||
/** Opaque Agent identity carried by one scoped Remote Event. */
|
||||
export type RemoteEventAgentId = Branded<'RemoteEventAgentId'>
|
||||
|
||||
/** One Host notification delivered to a Client generation. */
|
||||
export interface RemoteEventEmitFrame {
|
||||
readonly type: 'emit'
|
||||
readonly event: string
|
||||
readonly args: readonly unknown[]
|
||||
}
|
||||
|
||||
/** One pending Agent-scoped waterfall delivered to a Client generation. */
|
||||
export interface RemoteEventInvocationFrame {
|
||||
readonly type: 'waterfall'
|
||||
readonly event: string
|
||||
readonly eventId: RemoteEventId
|
||||
readonly agentId: RemoteEventAgentId
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Cancellation of a pending waterfall previously delivered under the same id. */
|
||||
export interface RemoteEventCancellationFrame {
|
||||
readonly type: 'cancel'
|
||||
readonly eventId: RemoteEventId
|
||||
}
|
||||
|
||||
/** Every item carried by the Gateway-internal forwarded-event stream. */
|
||||
export type RemoteEventDownlinkFrame =
|
||||
| RemoteEventReadyFrame
|
||||
| RemoteEventEmitFrame
|
||||
| RemoteEventInvocationFrame
|
||||
| RemoteEventCancellationFrame
|
||||
|
||||
/** JSON request fields plus the Host cancellation lifetime removed for transport. */
|
||||
export interface ProjectedRemoteEventRequest {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Error fields retained when a Client listener rejects a Host waterfall. */
|
||||
export interface RemoteEventRejection {
|
||||
readonly name: string
|
||||
readonly message: string
|
||||
readonly code?: string
|
||||
readonly details?: unknown
|
||||
}
|
||||
|
||||
/** Client response to one scoped Remote Event delivery. */
|
||||
export interface RemoteEventResult {
|
||||
readonly clientId: RemoteEventClientId
|
||||
readonly eventId: RemoteEventId
|
||||
readonly outcome:
|
||||
| { readonly kind: 'next' }
|
||||
| { readonly kind: 'result'; readonly value?: unknown }
|
||||
| { readonly kind: 'rejected'; readonly error: RemoteEventRejection }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one result sent through the Client's `$events/result` HTTP RPC.
|
||||
* @param value - untrusted result payload.
|
||||
* @returns validated event correlation and outcome fields.
|
||||
*/
|
||||
export function parseRemoteEventResult(value: unknown): RemoteEventResult {
|
||||
if (!isRecord(value)
|
||||
|| !exactKeys(value, ['clientId', 'eventId', 'outcome'])
|
||||
|| !isRemoteEventClientId(value.clientId)
|
||||
|| !isRemoteEventId(value.eventId)
|
||||
|| !isRecord(value.outcome)) {
|
||||
throw new Error('api gateway: invalid Remote event result')
|
||||
}
|
||||
const outcome = value.outcome
|
||||
if (outcome.kind === 'next' && exactKeys(outcome, ['kind'])) {
|
||||
return {
|
||||
clientId: value.clientId,
|
||||
eventId: value.eventId,
|
||||
outcome: { kind: 'next' },
|
||||
}
|
||||
}
|
||||
if (outcome.kind === 'result'
|
||||
&& (exactKeys(outcome, ['kind']) || exactKeys(outcome, ['kind', 'value']))
|
||||
&& (!Object.hasOwn(outcome, 'value') || isRemoteJsonValue(outcome.value))) {
|
||||
return {
|
||||
clientId: value.clientId,
|
||||
eventId: value.eventId,
|
||||
outcome: Object.hasOwn(outcome, 'value')
|
||||
? { kind: 'result', value: outcome.value }
|
||||
: { kind: 'result' },
|
||||
}
|
||||
}
|
||||
if (outcome.kind === 'rejected'
|
||||
&& exactKeys(outcome, ['kind', 'error'])) {
|
||||
return {
|
||||
clientId: value.clientId,
|
||||
eventId: value.eventId,
|
||||
outcome: { kind: 'rejected', error: parseRemoteEventRejection(outcome.error) },
|
||||
}
|
||||
}
|
||||
throw new Error('api gateway: invalid Remote event result')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the direct Agent and cancellation fields from one waterfall request.
|
||||
* @param value - request object before the waterfall's `next` callback.
|
||||
* @param subject - Agent used by the Cordis scope carrier.
|
||||
* @returns JSON-safe request fields and the optional Host cancellation signal.
|
||||
*/
|
||||
export function projectRemoteEventRequest(
|
||||
value: unknown,
|
||||
subject: object,
|
||||
): ProjectedRemoteEventRequest {
|
||||
if (!isPlainRecord(value) || !Object.hasOwn(value, 'agent') || value.agent !== subject) {
|
||||
throw new TypeError('api gateway: Remote event request must carry its scoped Agent directly')
|
||||
}
|
||||
const signal = value.signal
|
||||
if (signal !== undefined && !(signal instanceof AbortSignal)) {
|
||||
throw new TypeError('api gateway: Remote event request signal must be an AbortSignal')
|
||||
}
|
||||
const request: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (key === 'agent' || key === 'signal') continue
|
||||
const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined
|
||||
if (typeof key !== 'string' || descriptor?.enumerable !== true) {
|
||||
throw new TypeError('api gateway: Remote event request has a non-JSON property')
|
||||
}
|
||||
request[key] = Reflect.get(value, key)
|
||||
}
|
||||
if (!isRemoteJsonValue(request)) {
|
||||
throw new TypeError('api gateway: Remote event request is not lossless JSON data')
|
||||
}
|
||||
return {
|
||||
request,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an arbitrary rejection to stable, JSON-safe error fields.
|
||||
* @param reason - value thrown or rejected by a Client listener.
|
||||
* @returns wire-safe rejection fields.
|
||||
*/
|
||||
export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection {
|
||||
const record = typeof reason === 'object' && reason !== null ? reason : undefined
|
||||
const name = stringProperty(record, 'name') ?? 'Error'
|
||||
const message = stringProperty(record, 'message') ?? String(reason)
|
||||
const code = stringProperty(record, 'code')
|
||||
const details = record === undefined ? undefined : Reflect.get(record, 'details') as unknown
|
||||
return {
|
||||
name,
|
||||
message,
|
||||
...(code === undefined ? {} : { code }),
|
||||
...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a Client rejection for the Host continuation.
|
||||
* @param rejection - validated wire-safe error fields.
|
||||
* @returns an Error preserving the remote name, code, and JSON-safe details.
|
||||
*/
|
||||
export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error {
|
||||
const error = new Error(rejection.message) as Error & { code?: string; details?: unknown }
|
||||
error.name = rejection.name
|
||||
if (rejection.code !== undefined) error.code = rejection.code
|
||||
if (rejection.details !== undefined) error.details = rejection.details
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a value crosses JSON transport without coercion or omission.
|
||||
* @param value - candidate boundary value.
|
||||
* @returns whether the value is losslessly JSON-compatible.
|
||||
*/
|
||||
export function isRemoteJsonValue(value: unknown): boolean {
|
||||
return visitJsonValue(value, new Set<object>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a non-empty Remote Event correlation id at a wire boundary.
|
||||
* @param value - untrusted wire value.
|
||||
* @returns whether the value is a valid Remote Event id.
|
||||
*/
|
||||
export function isRemoteEventId(value: unknown): value is RemoteEventId {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a non-empty Remote Event Client id at a wire boundary.
|
||||
* @param value - untrusted wire value.
|
||||
* @returns whether the value identifies one event-stream generation.
|
||||
*/
|
||||
export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize the direct Agent identity used by a scoped Remote Event.
|
||||
* @param value - untrusted wire value.
|
||||
* @returns whether the value is a non-empty Agent identity.
|
||||
*/
|
||||
export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
/** One logical stream request sent from the browser. */
|
||||
export type RemoteStreamClientMessage =
|
||||
| {
|
||||
readonly type: 'open'
|
||||
readonly streamId: string
|
||||
readonly endpoint: string
|
||||
readonly payload: unknown
|
||||
}
|
||||
| { readonly type: 'cancel'; readonly streamId: string }
|
||||
|
||||
/** Carrier-safe failure delivered by the Host. */
|
||||
export interface RemoteStreamFailure {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
|
||||
/** One logical stream frame sent from the Host. */
|
||||
export type RemoteStreamServerMessage =
|
||||
| { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
|
||||
| { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure }
|
||||
| { readonly type: 'end'; readonly streamId: string }
|
||||
|
||||
/**
|
||||
* Parse and validate one browser-to-Host text message.
|
||||
* @param text - complete WebSocket text message.
|
||||
* @returns the validated logical-stream request.
|
||||
*/
|
||||
export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage {
|
||||
return parseMessage(text, (value) => {
|
||||
if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) {
|
||||
return value as unknown as RemoteStreamClientMessage
|
||||
}
|
||||
if (value.type === 'open'
|
||||
&& exactKeys(value, ['type', 'streamId', 'endpoint', 'payload'])
|
||||
&& validId(value.streamId)
|
||||
&& typeof value.endpoint === 'string'
|
||||
&& value.endpoint.length > 0) {
|
||||
return value as unknown as RemoteStreamClientMessage
|
||||
}
|
||||
throw new Error('api gateway: invalid Remote stream client message')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one Host-to-browser text message.
|
||||
* @param text - complete WebSocket text message.
|
||||
* @returns the validated logical-stream frame.
|
||||
*/
|
||||
export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage {
|
||||
return parseMessage(text, (value) => {
|
||||
if (value.type === 'item'
|
||||
&& (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value']))
|
||||
&& validId(value.streamId)) {
|
||||
return value as unknown as RemoteStreamServerMessage
|
||||
}
|
||||
if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) {
|
||||
return value as unknown as RemoteStreamServerMessage
|
||||
}
|
||||
if (value.type === 'error'
|
||||
&& exactKeys(value, ['type', 'streamId', 'error'])
|
||||
&& validId(value.streamId)
|
||||
&& isRecord(value.error)
|
||||
&& exactKeys(value.error, ['code', 'message', 'details'])
|
||||
&& typeof value.error.code === 'string'
|
||||
&& typeof value.error.message === 'string'
|
||||
&& isRecord(value.error.details)) {
|
||||
return value as unknown as RemoteStreamServerMessage
|
||||
}
|
||||
throw new Error('api gateway: invalid Remote stream server message')
|
||||
})
|
||||
}
|
||||
|
||||
function parseMessage<T>(text: string, validate: (value: Record<string, unknown>) => T): T {
|
||||
let decoded: unknown
|
||||
try {
|
||||
decoded = JSON.parse(text) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error('api gateway: Remote stream message is not JSON', { cause })
|
||||
}
|
||||
if (!isRecord(decoded)) throw new Error('api gateway: Remote stream message must be an object')
|
||||
return validate(decoded)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const keys = Reflect.ownKeys(value)
|
||||
return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key))
|
||||
}
|
||||
|
||||
function validId(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function parseRemoteEventRejection(value: unknown): RemoteEventRejection {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, ['name', 'message'], ['code', 'details'])
|
||||
|| typeof value.name !== 'string'
|
||||
|| value.name.length === 0
|
||||
|| typeof value.message !== 'string'
|
||||
|| (Object.hasOwn(value, 'code') && typeof value.code !== 'string')
|
||||
|| (Object.hasOwn(value, 'details') && !isRemoteJsonValue(value.details))) {
|
||||
throw new Error('api gateway: invalid Remote event rejection')
|
||||
}
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
...(typeof value.code === 'string' ? { code: value.code } : {}),
|
||||
...(Object.hasOwn(value, 'details') ? { details: value.details } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function hasOnlyKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
): boolean {
|
||||
const keys = Reflect.ownKeys(value)
|
||||
return required.every(key => Object.hasOwn(value, key))
|
||||
&& keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key)))
|
||||
}
|
||||
|
||||
function stringProperty(value: object | undefined, key: string): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const candidate: unknown = Reflect.get(value, key)
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
|
||||
function visitJsonValue(value: unknown, ancestors: Set<object>): boolean {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
|
||||
if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0)
|
||||
if (typeof value !== 'object') return false
|
||||
if (ancestors.has(value)) return false
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype
|
||||
|| Reflect.ownKeys(value).length !== value.length + 1) return false
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) return false
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (typeof key !== 'string') return false
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Host WebSocket owner for multiplexed Typert Remote streams. */
|
||||
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import WebSocket, { WebSocketServer, type RawData } from 'ws'
|
||||
import {
|
||||
parseRemoteStreamClientMessage,
|
||||
type RemoteStreamFailure,
|
||||
type RemoteStreamServerMessage,
|
||||
} from './stream-protocol.ts'
|
||||
|
||||
/** Open one validated Remote stream for a decoded wire request. */
|
||||
export type RemoteStreamOpener = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
/** Convert an invocation or carrier failure to a stable wire value. */
|
||||
export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure
|
||||
|
||||
/** Own the no-server WebSocket acceptor and every active logical stream. */
|
||||
export class RemoteStreamMuxServer {
|
||||
private readonly server = new WebSocketServer({ noServer: true })
|
||||
private readonly connections = new Set<Promise<void>>()
|
||||
|
||||
/**
|
||||
* @param open - Gateway stream dispatcher.
|
||||
* @param failure - Gateway error-to-wire mapper.
|
||||
*/
|
||||
constructor(
|
||||
private readonly open: RemoteStreamOpener,
|
||||
private readonly failure: RemoteStreamFailureMapper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upgrade one trusted request and begin serving its logical streams.
|
||||
* @param req - authenticated HTTP upgrade request.
|
||||
* @param socket - carrier socket transferred to the WebSocket server.
|
||||
* @param head - bytes already read after the HTTP upgrade headers.
|
||||
*/
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
||||
const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure)
|
||||
const done = connection.run()
|
||||
this.connections.add(done)
|
||||
void done.then(() => { this.connections.delete(done) })
|
||||
})
|
||||
}
|
||||
|
||||
/** Terminate all sockets and wait until every iterator has returned. */
|
||||
async close(): Promise<void> {
|
||||
for (const socket of this.server.clients) socket.terminate()
|
||||
const closed = Promise.withResolvers<void>()
|
||||
this.server.close((error) => {
|
||||
if (error === undefined) closed.resolve()
|
||||
else closed.reject(error)
|
||||
})
|
||||
await closed.promise
|
||||
await Promise.all(this.connections)
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveStream {
|
||||
readonly abort: AbortController
|
||||
done: Promise<void>
|
||||
}
|
||||
|
||||
class RemoteStreamMuxConnection {
|
||||
private readonly streams = new Map<string, ActiveStream>()
|
||||
private writes = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
private readonly socket: WebSocket,
|
||||
private readonly open: RemoteStreamOpener,
|
||||
private readonly failure: RemoteStreamFailureMapper,
|
||||
) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
this.socket.once('close', resolve)
|
||||
this.socket.once('error', () => { this.socket.terminate() })
|
||||
this.socket.on('message', (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
this.socket.close(1003, 'text messages required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.receive(rawText(data))
|
||||
} catch {
|
||||
this.socket.close(1008, 'invalid Remote stream request')
|
||||
}
|
||||
})
|
||||
})
|
||||
await closed
|
||||
const active = [...this.streams.values()]
|
||||
for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed'))
|
||||
await Promise.all(active.map(stream => stream.done))
|
||||
}
|
||||
|
||||
private receive(text: string): void {
|
||||
const message = parseRemoteStreamClientMessage(text)
|
||||
if (message.type === 'cancel') {
|
||||
this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled'))
|
||||
return
|
||||
}
|
||||
if (this.streams.has(message.streamId)) {
|
||||
throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`)
|
||||
}
|
||||
const abort = new AbortController()
|
||||
const active: ActiveStream = {
|
||||
abort,
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
this.streams.set(message.streamId, active)
|
||||
const done = this.pump(message.streamId, message.endpoint, message.payload, active)
|
||||
active.done = done
|
||||
const remove = (): void => { this.streams.delete(message.streamId) }
|
||||
void done.then(remove, remove)
|
||||
}
|
||||
|
||||
private async pump(
|
||||
streamId: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
active: ActiveStream,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const source = await this.open(endpoint, payload, active.abort.signal)
|
||||
for await (const value of source) {
|
||||
await this.send({ type: 'item', streamId, value })
|
||||
}
|
||||
if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
|
||||
} catch (error) {
|
||||
if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
await this.send({ type: 'error', streamId, error: this.failure(error) })
|
||||
} catch {
|
||||
// A terminal frame that cannot be encoded or written leaves the
|
||||
// logical stream ambiguous, so fail the physical generation.
|
||||
this.socket.close(1011, 'Remote stream failure could not be delivered')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: RemoteStreamServerMessage): Promise<void> {
|
||||
let text: string
|
||||
try {
|
||||
text = JSON.stringify(message)
|
||||
} catch (cause) {
|
||||
return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause }))
|
||||
}
|
||||
const delivery = this.writes.then(() => new Promise<void>((resolve, reject) => {
|
||||
if (this.socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('api gateway: Remote stream socket is closed'))
|
||||
return
|
||||
}
|
||||
this.socket.send(text, (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
}))
|
||||
this.writes = delivery.catch(() => undefined)
|
||||
return delivery
|
||||
}
|
||||
}
|
||||
|
||||
function rawText(data: RawData): string {
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
|
||||
return Buffer.from(data).toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an upgrade without transferring socket ownership to ws.
|
||||
* @param socket - carrier socket that receives the HTTP rejection.
|
||||
*/
|
||||
export function rejectRemoteStreamUpgrade(socket: Duplex): void {
|
||||
socket.end([
|
||||
'HTTP/1.1 403 Forbidden',
|
||||
'Connection: close',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Length: 9',
|
||||
'',
|
||||
'forbidden',
|
||||
].join('\r\n'))
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* @module @deepseek-ai/dsh-api-gateway/types
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
@@ -15,6 +17,85 @@ export interface InvokeRemoteRequest {
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */
|
||||
export interface TypertRemoteEventFrame {
|
||||
/** Original Host Cordis event name. */
|
||||
readonly event: string
|
||||
/** Original event argument list after the owner validates it for JSON transport. */
|
||||
readonly args: readonly unknown[]
|
||||
}
|
||||
|
||||
/** Live Host values used to project one scoped Remote Event. */
|
||||
export interface TypertRemoteEventContext {
|
||||
/** Live Host Context identified by the registered Host adapters. */
|
||||
readonly value: Context
|
||||
/** Agent object carried directly by the waterfall request. */
|
||||
readonly subject: object
|
||||
}
|
||||
|
||||
/** Result returned from a Client waterfall, or delegation back to the Host chain. */
|
||||
export type TypertRemoteEventOutcome =
|
||||
| { readonly kind: 'result'; readonly value: unknown }
|
||||
| { readonly kind: 'next' }
|
||||
|
||||
/**
|
||||
* One scoped waterfall invocation yielded by the application event source.
|
||||
* The Gateway alone assigns transport ids and resolves the continuation after
|
||||
* a Client result or explicit delegation.
|
||||
*/
|
||||
export interface TypertRemoteEventInvocation {
|
||||
/** Original Host Cordis event name. */
|
||||
readonly event: string
|
||||
/** Sole request argument before the waterfall's `next()` callback. */
|
||||
readonly request: object
|
||||
readonly context: TypertRemoteEventContext
|
||||
/** Resume the source's Cordis listener with a Client result or `next()`. */
|
||||
readonly resolve: (outcome: TypertRemoteEventOutcome) => void
|
||||
/** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */
|
||||
readonly reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
/** Notification or scoped waterfall accepted from the sole Remote Event source. */
|
||||
export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation
|
||||
|
||||
/**
|
||||
* Open the application-selected event stream for one Client carrier. The
|
||||
* factory must attach all incremental Host listeners before it returns; the
|
||||
* Gateway publishes its readiness item immediately afterward.
|
||||
* @param signal - cancellation shared with the Client stream and registration.
|
||||
* @returns the long-lived stream of notifications and scoped waterfall invocations.
|
||||
*/
|
||||
export type TypertRemoteEventSource = (
|
||||
signal: AbortSignal,
|
||||
) => AsyncIterable<TypertRemoteEventDispatch>
|
||||
|
||||
/** Carrier-facing access to decoded Remote streams and their stable failures. */
|
||||
export interface TypertGatewayWireStream {
|
||||
/**
|
||||
* Open one logical stream from its wire endpoint and payload.
|
||||
* @param endpoint - canonical Remote endpoint or Gateway-owned stream name.
|
||||
* @param payload - decoded carrier payload.
|
||||
* @param signal - logical-stream cancellation.
|
||||
* @returns validated stream values.
|
||||
*/
|
||||
readonly open: (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
/**
|
||||
* Convert a stream failure to the carrier-safe Remote failure fields.
|
||||
* @param error - failure raised while opening or consuming a stream.
|
||||
* @returns stable code, message, and details for the Client.
|
||||
*/
|
||||
readonly failure: (error: unknown) => {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
@@ -37,6 +118,16 @@ export type TypertGatewayErrorCode =
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/** Carrier adapter shared by WebSocket and in-process transports. */
|
||||
readonly wireStream: TypertGatewayWireStream
|
||||
|
||||
/**
|
||||
* Register the application-selected forwarded-event source.
|
||||
* @param source - stream factory installed by the Remote assembly.
|
||||
* @returns disposer removing this exact source and cancelling its active streams.
|
||||
*/
|
||||
registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
@@ -44,6 +135,13 @@ export interface TypertGateway {
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
|
||||
/**
|
||||
* Open one live stream Remote method without assuming a physical carrier.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns an iterable whose items have passed the generated result codec.
|
||||
*/
|
||||
stream(request: InvokeRemoteRequest): Promise<AsyncIterable<unknown>>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStream,
|
||||
} from '../src/client/index.ts'
|
||||
|
||||
const DESCRIPTION = {
|
||||
version: 'fixture',
|
||||
cwd: '/fixture',
|
||||
attachedSessions: 0,
|
||||
home: '/home/fixture',
|
||||
canOpenPath: true,
|
||||
}
|
||||
|
||||
function hostSource(initiallyAvailable: boolean): {
|
||||
connection: Pick<ConnectionHandle, 'hostDescription'>
|
||||
publish(available: boolean): void
|
||||
} {
|
||||
let current = initiallyAvailable ? DESCRIPTION : undefined
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
connection: {
|
||||
hostDescription: {
|
||||
getSnapshot: () => current,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
},
|
||||
publish: (available) => {
|
||||
current = available ? DESCRIPTION : undefined
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface Generation<Item> {
|
||||
readonly values?: readonly Item[]
|
||||
readonly terminal?: Error
|
||||
readonly hold?: boolean
|
||||
readonly afterAbortError?: Error
|
||||
readonly close?: () => Promise<void>
|
||||
}
|
||||
|
||||
function scripted<Item>(generations: Generation<Item>[], opened?: () => void) {
|
||||
return (signal: AbortSignal): AsyncIterable<Item> => ({
|
||||
async * [Symbol.asyncIterator](): AsyncIterator<Item> {
|
||||
const generation = generations.shift()
|
||||
if (generation === undefined) throw new Error('fixture has no stream generation')
|
||||
opened?.()
|
||||
try {
|
||||
for (const value of generation.values ?? []) yield value
|
||||
if (generation.terminal !== undefined) throw generation.terminal
|
||||
if (generation.hold === true && !signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
if (generation.afterAbortError !== undefined) throw generation.afterAbortError
|
||||
} finally {
|
||||
await generation.close?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function supervisor<Item>(
|
||||
connection: Pick<ConnectionHandle, 'hostDescription'>,
|
||||
generations: Generation<Item>[],
|
||||
carrierFailed?: (error: RemoteStreamCarrierError) => void,
|
||||
): RemoteStream<Item> {
|
||||
return new RemoteStream(connection, {
|
||||
name: 'fixture stream',
|
||||
open: scripted(generations),
|
||||
ended: accepted => accepted
|
||||
? new RemoteStreamCarrierError('accepted generation ended')
|
||||
: new Error('generation ended before acceptance'),
|
||||
...(carrierFailed === undefined ? {} : { carrierFailed }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('RemoteStream', () => {
|
||||
it('annotates replacement generations and resets retry state after acceptance', async () => {
|
||||
const source = hostSource(true)
|
||||
const stream = supervisor(source.connection, [
|
||||
{ values: ['first'], terminal: new RemoteStreamCarrierError('first lost') },
|
||||
{ values: ['second'], hold: true },
|
||||
])
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
const first = await iterator.next()
|
||||
expect(first).toMatchObject({ done: false, value: { generation: 1, value: 'first' } })
|
||||
if (first.done) throw new Error('fixture generation ended early')
|
||||
first.value.accept()
|
||||
const second = await iterator.next()
|
||||
expect(second).toMatchObject({ done: false, value: { generation: 2, value: 'second' } })
|
||||
if (second.done) throw new Error('fixture replacement ended early')
|
||||
second.value.accept()
|
||||
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('permits one isolated retry while the Host remains available', async () => {
|
||||
const source = hostSource(true)
|
||||
const first = new RemoteStreamCarrierError('first carrier failure')
|
||||
const repeated = new RemoteStreamCarrierError('isolated retry failed')
|
||||
const carrierFailed = vi.fn<(error: RemoteStreamCarrierError) => void>()
|
||||
const stream = supervisor(source.connection, [
|
||||
{ terminal: first },
|
||||
{ terminal: repeated },
|
||||
], carrierFailed)
|
||||
|
||||
await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated)
|
||||
expect(carrierFailed).toHaveBeenNthCalledWith(1, first)
|
||||
expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated)
|
||||
})
|
||||
|
||||
it('waits for a replacement Host generation after observing unavailability', async () => {
|
||||
const source = hostSource(false)
|
||||
let opened = 0
|
||||
const stream = new RemoteStream(source.connection, {
|
||||
name: 'fixture stream',
|
||||
open: scripted([
|
||||
{ terminal: new RemoteStreamCarrierError('offline') },
|
||||
{ values: ['ready'], hold: true },
|
||||
], () => { opened++ }),
|
||||
ended: () => new Error('ended'),
|
||||
})
|
||||
const pending = stream[Symbol.asyncIterator]().next()
|
||||
await vi.waitFor(() => { expect(opened).toBe(1) })
|
||||
|
||||
source.publish(false)
|
||||
expect(opened).toBe(1)
|
||||
source.publish(true)
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { generation: 2, value: 'ready' },
|
||||
})
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('contains a Host publication during subscription setup', async () => {
|
||||
let reads = 0
|
||||
let disposed = 0
|
||||
const connection = {
|
||||
hostDescription: {
|
||||
getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION,
|
||||
subscribe: (listener: () => void) => {
|
||||
listener()
|
||||
return () => { disposed++ }
|
||||
},
|
||||
},
|
||||
}
|
||||
const stream = supervisor(connection, [
|
||||
{ terminal: new RemoteStreamCarrierError('offline') },
|
||||
{ values: ['ready'], hold: true },
|
||||
])
|
||||
|
||||
await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
|
||||
value: { generation: 2, value: 'ready' },
|
||||
})
|
||||
expect(disposed).toBe(1)
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('restarts with a fresh physical generation', async () => {
|
||||
const source = hostSource(true)
|
||||
const stream = supervisor(source.connection, [
|
||||
{ values: ['first'], hold: true },
|
||||
{ values: ['second'], hold: true },
|
||||
])
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 1, value: 'first' } })
|
||||
|
||||
stream.restart()
|
||||
|
||||
await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 2, value: 'second' } })
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('drops values and cancellation failures from a replaced generation', async () => {
|
||||
const source = hostSource(true)
|
||||
const stream = supervisor(source.connection, [
|
||||
{ values: ['first', 'stale'] },
|
||||
{
|
||||
values: ['second'],
|
||||
hold: true,
|
||||
afterAbortError: new Error('replaced generation cancelled'),
|
||||
},
|
||||
{ values: ['third'], hold: true },
|
||||
])
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (first.done) throw new Error('fixture generation ended early')
|
||||
|
||||
stream.restart()
|
||||
first.value.accept()
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: { generation: 2, value: 'second' },
|
||||
})
|
||||
|
||||
stream.restart()
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
value: { generation: 3, value: 'third' },
|
||||
})
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('honors replacement requested by carrier diagnostics', async () => {
|
||||
const source = hostSource(true)
|
||||
const holder: { stream?: RemoteStream<string> } = {}
|
||||
const carrierFailed = vi.fn(() => { holder.stream?.restart() })
|
||||
const stream = supervisor(source.connection, [
|
||||
{ terminal: new RemoteStreamCarrierError('replace this generation') },
|
||||
{ values: ['ready'], hold: true },
|
||||
], carrierFailed)
|
||||
holder.stream = stream
|
||||
|
||||
await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
|
||||
value: { generation: 2, value: 'ready' },
|
||||
})
|
||||
expect(carrierFailed).toHaveBeenCalledOnce()
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('contains replacement during Host-readiness subscription setup', async () => {
|
||||
const holder: { stream?: RemoteStream<string> } = {}
|
||||
let subscriptions = 0
|
||||
const connection = {
|
||||
hostDescription: {
|
||||
getSnapshot: () => undefined,
|
||||
subscribe: () => {
|
||||
subscriptions++
|
||||
holder.stream?.restart()
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const stream = supervisor(connection, [
|
||||
{ terminal: new RemoteStreamCarrierError('offline') },
|
||||
{ values: ['ready'], hold: true },
|
||||
])
|
||||
holder.stream = stream
|
||||
|
||||
await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({
|
||||
value: { generation: 2, value: 'ready' },
|
||||
})
|
||||
expect(subscriptions).toBe(1)
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('waits for generation cleanup during disposal', async () => {
|
||||
const source = hostSource(true)
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let closed = false
|
||||
const stream = supervisor(source.connection, [{
|
||||
values: ['ready'],
|
||||
hold: true,
|
||||
close: async () => {
|
||||
await release.promise
|
||||
closed = true
|
||||
},
|
||||
}])
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
const pending = iterator.next()
|
||||
|
||||
const disposing = stream.dispose()
|
||||
expect(stream.dispose()).toBe(disposing)
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
await expect(pending).resolves.toEqual({ done: true, value: undefined })
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the domain normal-end classification and permits one consumer', async () => {
|
||||
const source = hostSource(true)
|
||||
const stream = supervisor<string>(source.connection, [{}])
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
expect(() => stream[Symbol.asyncIterator]()).toThrow('already has a consumer')
|
||||
await expect(iterator.next()).rejects.toThrow('generation ended before acceptance')
|
||||
await stream.dispose()
|
||||
})
|
||||
|
||||
it('can be disposed before consumption and ignores later restart', async () => {
|
||||
const source = hostSource(true)
|
||||
const stream = supervisor<string>(source.connection, [])
|
||||
|
||||
await stream.dispose()
|
||||
expect(stream.signal.aborted).toBe(true)
|
||||
stream.restart()
|
||||
await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1046,6 +1046,56 @@ describe('TypertGatewayService', () => {
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims and validates in-process Remote event results for the active Client generation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(FakeConnectionService)
|
||||
await ctx.plugin(TypertGatewayService)
|
||||
const connection = rawConnection(ctx)
|
||||
const handler = connection.handler
|
||||
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
|
||||
expect(connection.matches?.('$events/result')).toBe(true)
|
||||
|
||||
const result = {
|
||||
args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } },
|
||||
}
|
||||
const inactive = await handler('$events/result', result, new AbortController().signal)
|
||||
expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded')
|
||||
expect(inactive.error.message).toContain('identifies no active event stream')
|
||||
|
||||
const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})())
|
||||
const carrier = new AbortController()
|
||||
const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal)
|
||||
const opening = await events.next()
|
||||
expect(opening).toMatchObject({ done: false, value: { type: 'ready' } })
|
||||
if (opening.done) throw new Error('Remote event stream ended before ready')
|
||||
const clientId: unknown = Reflect.get(opening.value as object, 'clientId')
|
||||
if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id')
|
||||
|
||||
for (const payload of [null, [], {}, { other: {} }]) {
|
||||
const invalid = await handler('$events/result', payload, carrier.signal)
|
||||
expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toContain('requires exactly one plain-object args field')
|
||||
}
|
||||
await expect(handler('$events/result', {
|
||||
args: { clientId, eventId: 'missing', outcome: { kind: 'next' } },
|
||||
}, carrier.signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
await events.return(undefined)
|
||||
await unregister()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('preserves a lookup policy rejection through the Connection RPC result', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
@@ -1228,6 +1278,17 @@ function rawConnection(ctx: Context): FakeConnectionService {
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
interface GatewayEventHarness {
|
||||
openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator
|
||||
}
|
||||
|
||||
function rawGatewayEventHarness(ctx: Context): GatewayEventHarness {
|
||||
const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & {
|
||||
[symbols.original]?: GatewayEventHarness
|
||||
}
|
||||
return receiver[symbols.original] ?? receiver
|
||||
}
|
||||
|
||||
function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise<void> {
|
||||
return ctx.typert.register({
|
||||
package: '@fixture/gateway',
|
||||
@@ -1256,6 +1317,7 @@ function contextProvider(context: Context) {
|
||||
return {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/domain#AgentId',
|
||||
identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined,
|
||||
resolve: (id: string) => id === 'agent-1' ? context : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RemoteJournalStream,
|
||||
RemoteStream,
|
||||
RemoteStreamCarrierError,
|
||||
type RemoteJournalChange,
|
||||
type RemoteJournalFrame,
|
||||
type RemoteStreamOptions,
|
||||
} from '../src/client/index.ts'
|
||||
|
||||
interface Entry {
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
interface Page {
|
||||
readonly entries: readonly Entry[]
|
||||
readonly hasMore: boolean
|
||||
readonly marker: string
|
||||
}
|
||||
|
||||
interface PageRequest {
|
||||
readonly before?: number
|
||||
readonly limit?: number
|
||||
}
|
||||
|
||||
interface Generation {
|
||||
readonly frames: readonly RemoteJournalFrame<Entry, number>[]
|
||||
readonly terminal?: Error
|
||||
readonly hold?: boolean
|
||||
}
|
||||
|
||||
const AVAILABLE_CONNECTION = {
|
||||
hostDescription: {
|
||||
getSnapshot: () => ({
|
||||
version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
|
||||
}),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
}
|
||||
|
||||
const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq }))
|
||||
|
||||
const page = (marker: string, seqs: number[], hasMore = false): Page => ({
|
||||
entries: entries(...seqs),
|
||||
hasMore,
|
||||
marker,
|
||||
})
|
||||
|
||||
const STREAM_FACTORY = {
|
||||
$stream<Item>(options: RemoteStreamOptions<Item>): RemoteStream<Item> {
|
||||
return new RemoteStream(AVAILABLE_CONNECTION, options)
|
||||
},
|
||||
}
|
||||
|
||||
class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageRequest> {
|
||||
constructor(
|
||||
private readonly generations: Generation[],
|
||||
private readonly pages: (Page | Promise<Page>)[],
|
||||
private readonly calls: string[],
|
||||
private readonly pageRequests: PageRequest[],
|
||||
private readonly followCursors: (number | undefined)[],
|
||||
changes: RemoteJournalChange<Page, Entry>[],
|
||||
failed: (error: unknown) => void,
|
||||
) {
|
||||
super(STREAM_FACTORY, {
|
||||
name: 'fixture journal',
|
||||
emptyCursor: -1,
|
||||
entries: value => value.entries,
|
||||
hasMore: value => value.hasMore,
|
||||
cursor: entry => entry.seq,
|
||||
compare: (left, right) => left - right,
|
||||
follows: (left, right) => right === left + 1,
|
||||
publish: (change) => { changes.push(change) },
|
||||
failed,
|
||||
})
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override async * follow(
|
||||
after: number | undefined,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<RemoteJournalFrame<Entry, number>> {
|
||||
this.calls.push('follow')
|
||||
this.followCursors.push(after)
|
||||
const generation = this.generations.shift()
|
||||
if (generation === undefined) throw new Error('no scripted journal generation')
|
||||
for (const frame of generation.frames) yield frame
|
||||
if (generation.terminal !== undefined) throw generation.terminal
|
||||
if (generation.hold === true && !signal.aborted) {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override readPage(request: PageRequest): Promise<Page> {
|
||||
this.calls.push('page')
|
||||
this.pageRequests.push(request)
|
||||
const value = this.pages.shift()
|
||||
if (value === undefined) throw new Error('no scripted journal page')
|
||||
return Promise.resolve(value)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
protected override repairRequest(request: PageRequest): PageRequest {
|
||||
return request.limit === undefined ? {} : { limit: request.limit }
|
||||
}
|
||||
}
|
||||
|
||||
function journalFixture(
|
||||
generations: Generation[],
|
||||
pages: (Page | Promise<Page>)[],
|
||||
): {
|
||||
readonly journal: RemoteJournalStream<Page, Entry, number, PageRequest>
|
||||
readonly changes: RemoteJournalChange<Page, Entry>[]
|
||||
readonly failed: ReturnType<typeof vi.fn>
|
||||
readonly calls: string[]
|
||||
readonly pageRequests: PageRequest[]
|
||||
readonly followCursors: (number | undefined)[]
|
||||
} {
|
||||
const calls: string[] = []
|
||||
const pageRequests: PageRequest[] = []
|
||||
const followCursors: (number | undefined)[] = []
|
||||
const changes: RemoteJournalChange<Page, Entry>[] = []
|
||||
const failed = vi.fn()
|
||||
const journal = new FixtureJournal(
|
||||
generations,
|
||||
pages,
|
||||
calls,
|
||||
pageRequests,
|
||||
followCursors,
|
||||
changes,
|
||||
failed,
|
||||
)
|
||||
return { journal, changes, failed, calls, pageRequests, followCursors }
|
||||
}
|
||||
|
||||
describe('RemoteJournalStream', () => {
|
||||
it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => {
|
||||
const fixture = journalFixture(
|
||||
[{
|
||||
frames: [
|
||||
{ type: 'opened', cursor: 3 },
|
||||
{ type: 'entry', entry: { seq: 3 } },
|
||||
{ type: 'entry', entry: { seq: 4 } },
|
||||
],
|
||||
hold: true,
|
||||
}],
|
||||
[page('tail', [2, 3], true), page('older', [0, 1])],
|
||||
)
|
||||
|
||||
await fixture.journal.open({ limit: 2 })
|
||||
await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
|
||||
await fixture.journal.prepend({ before: 2, limit: 2 })
|
||||
|
||||
expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page'])
|
||||
expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }])
|
||||
expect(fixture.changes).toEqual([
|
||||
{ type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true },
|
||||
{ type: 'append', entry: { seq: 4 } },
|
||||
{ type: 'prepend', page: page('older', [0, 1]), entries: entries(0, 1), hasMore: false },
|
||||
])
|
||||
await fixture.journal.dispose()
|
||||
await fixture.journal.dispose()
|
||||
})
|
||||
|
||||
it('publishes one sorted replacement from a repair page and live entries queued while it loads', async () => {
|
||||
let resolveRepair!: (value: Page) => void
|
||||
const repair = new Promise<Page>((resolve) => { resolveRepair = resolve })
|
||||
const fixture = journalFixture(
|
||||
[{
|
||||
frames: [
|
||||
{ type: 'opened', cursor: 15 },
|
||||
{ type: 'entry', entry: { seq: 17 } },
|
||||
{ type: 'entry', entry: { seq: 16 } },
|
||||
],
|
||||
hold: true,
|
||||
}],
|
||||
[page('stale', [6, 7, 8, 9, 10, 11]), repair],
|
||||
)
|
||||
|
||||
const opening = fixture.journal.open({ limit: 6 })
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.calls.filter(call => call === 'page')).toHaveLength(2)
|
||||
})
|
||||
expect(fixture.changes).toEqual([])
|
||||
|
||||
resolveRepair(page('repair', [10, 11, 12, 13, 14, 15]))
|
||||
await opening
|
||||
|
||||
expect(fixture.changes).toEqual([{
|
||||
type: 'replace',
|
||||
page: page('repair', [10, 11, 12, 13, 14, 15]),
|
||||
entries: entries(10, 11, 12, 13, 14, 15, 16, 17),
|
||||
hasMore: false,
|
||||
}])
|
||||
await fixture.journal.dispose()
|
||||
})
|
||||
|
||||
it('repairs a replacement generation through one tail page and drops replay overlap', async () => {
|
||||
const lost = new RemoteStreamCarrierError('carrier lost')
|
||||
const fixture = journalFixture(
|
||||
[
|
||||
{
|
||||
frames: [
|
||||
{ type: 'opened', cursor: 1 },
|
||||
{ type: 'entry', entry: { seq: 2 } },
|
||||
],
|
||||
terminal: lost,
|
||||
},
|
||||
{
|
||||
frames: [
|
||||
{ type: 'opened', cursor: 4 },
|
||||
{ type: 'entry', entry: { seq: 3 } },
|
||||
{ type: 'entry', entry: { seq: 4 } },
|
||||
],
|
||||
hold: true,
|
||||
},
|
||||
],
|
||||
[page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
|
||||
)
|
||||
|
||||
await fixture.journal.open({ limit: 5 })
|
||||
await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) })
|
||||
|
||||
expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
|
||||
expect(fixture.changes[2]).toMatchObject({
|
||||
type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4),
|
||||
})
|
||||
expect(fixture.followCursors).toEqual([undefined, 2])
|
||||
expect(fixture.failed).not.toHaveBeenCalled()
|
||||
await fixture.journal.dispose()
|
||||
})
|
||||
|
||||
it('repairs a live gap before publishing another change', async () => {
|
||||
const fixture = journalFixture(
|
||||
[{
|
||||
frames: [
|
||||
{ type: 'opened', cursor: 1 },
|
||||
{ type: 'entry', entry: { seq: 4 } },
|
||||
],
|
||||
hold: true,
|
||||
}],
|
||||
[page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])],
|
||||
)
|
||||
|
||||
await fixture.journal.open({})
|
||||
await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
|
||||
|
||||
expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace'])
|
||||
expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } })
|
||||
await fixture.journal.dispose()
|
||||
})
|
||||
|
||||
it('rejects malformed opening and page sequences', async () => {
|
||||
const beforeOpening = journalFixture(
|
||||
[{ frames: [{ type: 'entry', entry: { seq: 0 } }] }],
|
||||
[page('unused', [])],
|
||||
)
|
||||
await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor')
|
||||
|
||||
const discontinuousPage = journalFixture(
|
||||
[{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
|
||||
[page('bad', [0, 2, 3])],
|
||||
)
|
||||
await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries')
|
||||
|
||||
const shortPage = journalFixture(
|
||||
[{ frames: [{ type: 'opened', cursor: 3 }], hold: true }],
|
||||
[page('short', [0, 1]), page('repair-short', [0, 1, 2])],
|
||||
)
|
||||
await expect(shortPage.journal.open({})).rejects.toThrow('page did not reach its opening cursor')
|
||||
})
|
||||
|
||||
it('reports duplicate and regressed generation cursors as terminal failures', async () => {
|
||||
const duplicate = journalFixture(
|
||||
[{
|
||||
frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }],
|
||||
}],
|
||||
[page('initial', [0, 1])],
|
||||
)
|
||||
await duplicate.journal.open({})
|
||||
await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() })
|
||||
const duplicateFailure: unknown = duplicate.failed.mock.calls[0]?.[0]
|
||||
expect(duplicateFailure).toBeInstanceOf(Error)
|
||||
if (!(duplicateFailure instanceof Error)) throw new Error('expected duplicate-cursor failure')
|
||||
expect(duplicateFailure.message).toContain('more than one opening cursor')
|
||||
|
||||
const regressed = journalFixture(
|
||||
[
|
||||
{
|
||||
frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }],
|
||||
terminal: new RemoteStreamCarrierError('lost'),
|
||||
},
|
||||
{ frames: [{ type: 'opened', cursor: 1 }] },
|
||||
],
|
||||
[page('initial', [0, 1])],
|
||||
)
|
||||
await regressed.journal.open({})
|
||||
await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() })
|
||||
const regressedFailure: unknown = regressed.failed.mock.calls[0]?.[0]
|
||||
expect(regressedFailure).toBeInstanceOf(Error)
|
||||
if (!(regressedFailure instanceof Error)) throw new Error('expected regressed-cursor failure')
|
||||
expect(regressedFailure.message).toContain('behind the last applied entry')
|
||||
})
|
||||
|
||||
it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => {
|
||||
const fixture = journalFixture(
|
||||
[{ frames: [{ type: 'opened', cursor: 4 }], hold: true }],
|
||||
[page('initial', [3, 4], true), page('older', [0, 1], true)],
|
||||
)
|
||||
await fixture.journal.open({})
|
||||
|
||||
await expect(fixture.journal.prepend({ before: 3 })).rejects.toThrow('history page is discontinuous')
|
||||
expect(fixture.changes.at(-1)).toEqual({
|
||||
type: 'prepend', page: page('older', [0, 1], true), entries: [], hasMore: false,
|
||||
})
|
||||
await fixture.journal.dispose()
|
||||
})
|
||||
|
||||
it('guards lifecycle operations before and after open', async () => {
|
||||
const fixture = journalFixture(
|
||||
[{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
|
||||
[page('empty', [])],
|
||||
)
|
||||
|
||||
await expect(fixture.journal.prepend({})).rejects.toThrow('is not open')
|
||||
await fixture.journal.open({})
|
||||
await expect(fixture.journal.open({})).rejects.toThrow('already opened')
|
||||
fixture.journal.restart()
|
||||
await fixture.journal.dispose()
|
||||
await expect(fixture.journal.prepend({})).rejects.toThrow('is not open')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRemoteJsonValue,
|
||||
parseRemoteEventResult,
|
||||
parseRemoteStreamClientMessage,
|
||||
projectRemoteEventRequest,
|
||||
projectRemoteEventRejection,
|
||||
restoreRemoteEventRejection,
|
||||
} from '../src/stream-protocol.ts'
|
||||
|
||||
describe('Remote Event result protocol', () => {
|
||||
it('accepts delegation, values, and structured rejections', () => {
|
||||
expect(parseRemoteEventResult({
|
||||
clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' },
|
||||
})).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } })
|
||||
expect(parseRemoteEventResult({
|
||||
clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' },
|
||||
})).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } })
|
||||
expect(parseRemoteEventResult({
|
||||
clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
|
||||
})).toEqual({
|
||||
clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } },
|
||||
})
|
||||
expect(parseRemoteEventResult({
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-minimal',
|
||||
outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
|
||||
})).toEqual({
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-minimal',
|
||||
outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } },
|
||||
})
|
||||
expect(parseRemoteEventResult({
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-4',
|
||||
outcome: {
|
||||
kind: 'rejected',
|
||||
error: {
|
||||
name: 'ApprovalError',
|
||||
message: 'declined',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-4',
|
||||
outcome: {
|
||||
kind: 'rejected',
|
||||
error: {
|
||||
name: 'ApprovalError',
|
||||
message: 'declined',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ clientId: '', eventId: 'event-1', outcome: { kind: 'next' } },
|
||||
{ clientId: 'client-1', eventId: '', outcome: { kind: 'next' } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: null },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } },
|
||||
{ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } },
|
||||
{
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-1',
|
||||
outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } },
|
||||
},
|
||||
{
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-1',
|
||||
outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } },
|
||||
},
|
||||
{
|
||||
clientId: 'client-1',
|
||||
eventId: 'event-1',
|
||||
outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } },
|
||||
},
|
||||
])('rejects an invalid result frame: %#', (value) => {
|
||||
expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event')
|
||||
})
|
||||
|
||||
it('rejects symbol properties in rejection records', () => {
|
||||
const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true }
|
||||
expect(() => parseRemoteEventResult({
|
||||
clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error },
|
||||
})).toThrow('api gateway: invalid Remote event rejection')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Event request projection', () => {
|
||||
it('removes only the direct Agent and signal fields', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
const abort = new AbortController()
|
||||
const nested = { agent, signal: 'payload' }
|
||||
const projected = projectRemoteEventRequest({
|
||||
agent,
|
||||
signal: abort.signal,
|
||||
prompt: 'approve?',
|
||||
nested,
|
||||
}, agent)
|
||||
|
||||
expect(projected).toEqual({
|
||||
request: { prompt: 'approve?', nested },
|
||||
signal: abort.signal,
|
||||
})
|
||||
expect(Object.getPrototypeOf(projected.request)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a null-prototype request and an omitted signal', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
const request = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
agent,
|
||||
accepted: true,
|
||||
})
|
||||
expect(projectRemoteEventRequest(request, agent)).toEqual({
|
||||
request: { accepted: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires the scoped Agent as a direct own field', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
expect(() => projectRemoteEventRequest(null, agent))
|
||||
.toThrow('must carry its scoped Agent directly')
|
||||
expect(() => projectRemoteEventRequest({}, agent))
|
||||
.toThrow('must carry its scoped Agent directly')
|
||||
expect(() => projectRemoteEventRequest({ agent: {} }, agent))
|
||||
.toThrow('must carry its scoped Agent directly')
|
||||
expect(() => projectRemoteEventRequest(Object.create({ agent }), agent))
|
||||
.toThrow('must carry its scoped Agent directly')
|
||||
})
|
||||
|
||||
it('rejects an invalid direct signal', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent))
|
||||
.toThrow('request signal must be an AbortSignal')
|
||||
})
|
||||
|
||||
it('rejects non-JSON payload fields', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent))
|
||||
.toThrow('request is not lossless JSON data')
|
||||
|
||||
const cycle: Record<string, unknown> = {}
|
||||
cycle.self = cycle
|
||||
expect(() => projectRemoteEventRequest({ agent, cycle }, agent))
|
||||
.toThrow('request is not lossless JSON data')
|
||||
})
|
||||
|
||||
it('rejects symbol and non-enumerable payload fields', () => {
|
||||
const agent = { kind: 'agent' }
|
||||
expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent))
|
||||
.toThrow('request has a non-JSON property')
|
||||
|
||||
const hidden = { agent }
|
||||
Object.defineProperty(hidden, 'value', { value: true })
|
||||
expect(() => projectRemoteEventRequest(hidden, agent))
|
||||
.toThrow('request has a non-JSON property')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Event rejection projection', () => {
|
||||
it('preserves stable error fields in both directions', () => {
|
||||
const reason = Object.assign(new Error('declined'), {
|
||||
name: 'ApprovalError',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
})
|
||||
expect(projectRemoteEventRejection(reason)).toEqual({
|
||||
name: 'ApprovalError',
|
||||
message: 'declined',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
})
|
||||
|
||||
const restored = restoreRemoteEventRejection({
|
||||
name: 'ApprovalError',
|
||||
message: 'declined',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
}) as Error & { code?: string; details?: unknown }
|
||||
expect(restored).toMatchObject({
|
||||
name: 'ApprovalError',
|
||||
message: 'declined',
|
||||
code: 'DECLINED',
|
||||
details: { retryable: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes arbitrary reasons and omits non-JSON optional fields', () => {
|
||||
expect(projectRemoteEventRejection('offline')).toEqual({
|
||||
name: 'Error', message: 'offline',
|
||||
})
|
||||
expect(projectRemoteEventRejection(undefined)).toEqual({
|
||||
name: 'Error', message: 'undefined',
|
||||
})
|
||||
expect(projectRemoteEventRejection({
|
||||
name: 1, message: 2, code: 3, details: 1n,
|
||||
})).toEqual({
|
||||
name: 'Error', message: '[object Object]',
|
||||
})
|
||||
|
||||
const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' })
|
||||
expect(restored).toMatchObject({ name: 'Error', message: 'offline' })
|
||||
expect(restored).not.toHaveProperty('code')
|
||||
expect(restored).not.toHaveProperty('details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote Event JSON values', () => {
|
||||
it('accepts lossless JSON values, null-prototype objects, and repeated references', () => {
|
||||
const shared = { value: 1 }
|
||||
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
enabled: true,
|
||||
})
|
||||
expect(isRemoteJsonValue({
|
||||
null: null,
|
||||
string: 'value',
|
||||
boolean: true,
|
||||
number: 1.5,
|
||||
array: [shared, shared],
|
||||
nullPrototype,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
1n,
|
||||
Symbol('value'),
|
||||
() => undefined,
|
||||
NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
-0,
|
||||
])('rejects a non-lossless scalar: %s', (value) => {
|
||||
expect(isRemoteJsonValue(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects cycles and non-plain arrays and objects', () => {
|
||||
const cycle: Record<string, unknown> = {}
|
||||
cycle.self = cycle
|
||||
expect(isRemoteJsonValue(cycle)).toBe(false)
|
||||
|
||||
class Fixture {
|
||||
value = 1
|
||||
}
|
||||
expect(isRemoteJsonValue(new Fixture())).toBe(false)
|
||||
|
||||
const customArray = [1]
|
||||
Object.setPrototypeOf(customArray, null)
|
||||
expect(isRemoteJsonValue(customArray)).toBe(false)
|
||||
expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false)
|
||||
|
||||
const sparse = new Array<unknown>(2)
|
||||
sparse[1] = 'value'
|
||||
expect(isRemoteJsonValue(sparse)).toBe(false)
|
||||
const disguisedSparse = Object.assign(new Array<unknown>(2), { extra: true })
|
||||
disguisedSparse[1] = 'value'
|
||||
expect(isRemoteJsonValue(disguisedSparse)).toBe(false)
|
||||
expect(isRemoteJsonValue([undefined])).toBe(false)
|
||||
|
||||
const symbolic = { [Symbol('value')]: true }
|
||||
expect(isRemoteJsonValue(symbolic)).toBe(false)
|
||||
const hidden = {}
|
||||
Object.defineProperty(hidden, 'value', { value: true })
|
||||
expect(isRemoteJsonValue(hidden)).toBe(false)
|
||||
expect(isRemoteJsonValue({ nested: undefined })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remote stream client protocol', () => {
|
||||
it('rejects the removed logical-stream input message', () => {
|
||||
expect(() => parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'input', streamId: 'stream-1', value: { answer: true },
|
||||
}))).toThrow('api gateway: invalid Remote stream client message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseRemoteStreamClientMessage,
|
||||
parseRemoteStreamServerMessage,
|
||||
} from '../src/stream-protocol.ts'
|
||||
|
||||
describe('Remote stream wire protocol', () => {
|
||||
it('accepts every client message variant', () => {
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 },
|
||||
}))).toEqual({
|
||||
type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 },
|
||||
})
|
||||
expect(parseRemoteStreamClientMessage(JSON.stringify({
|
||||
type: 'cancel', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'cancel', streamId: 'stream-1' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ type: 'open', streamId: '', endpoint: 'feed/follow', payload: {} },
|
||||
{ type: 'open', streamId: 'stream-1', endpoint: '', payload: {} },
|
||||
{ type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' },
|
||||
{ type: 'cancel', streamId: 'stream-1', extra: true },
|
||||
{ type: 'unknown', streamId: 'stream-1' },
|
||||
])('rejects an invalid client message: %j', (message) => {
|
||||
expect(() => parseRemoteStreamClientMessage(JSON.stringify(message)))
|
||||
.toThrow('api gateway: invalid Remote stream client message')
|
||||
})
|
||||
|
||||
it('accepts every server message variant', () => {
|
||||
expect(parseRemoteStreamServerMessage(JSON.stringify({
|
||||
type: 'item', streamId: 'stream-1', value: null,
|
||||
}))).toEqual({ type: 'item', streamId: 'stream-1', value: null })
|
||||
expect(parseRemoteStreamServerMessage(JSON.stringify({
|
||||
type: 'item', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'item', streamId: 'stream-1' })
|
||||
expect(parseRemoteStreamServerMessage(JSON.stringify({
|
||||
type: 'error',
|
||||
streamId: 'stream-1',
|
||||
error: { code: 'offline', message: 'connection lost', details: {} },
|
||||
}))).toEqual({
|
||||
type: 'error',
|
||||
streamId: 'stream-1',
|
||||
error: { code: 'offline', message: 'connection lost', details: {} },
|
||||
})
|
||||
expect(parseRemoteStreamServerMessage(JSON.stringify({
|
||||
type: 'end', streamId: 'stream-1',
|
||||
}))).toEqual({ type: 'end', streamId: 'stream-1' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ type: 'item', streamId: '', value: 'item' },
|
||||
{ type: 'item', streamId: 'stream-1', extra: true },
|
||||
{ type: 'end', streamId: 'stream-1', extra: true },
|
||||
{ type: 'error', streamId: 'stream-1', error: [] },
|
||||
{ type: 'error', streamId: 'stream-1', error: { code: 1, message: 'failure', details: {} } },
|
||||
{ type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 1, details: {} } },
|
||||
{ type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 'failure', details: [] } },
|
||||
{ type: 'unknown', streamId: 'stream-1' },
|
||||
])('rejects an invalid server message: %j', (message) => {
|
||||
expect(() => parseRemoteStreamServerMessage(JSON.stringify(message)))
|
||||
.toThrow('api gateway: invalid Remote stream server message')
|
||||
})
|
||||
|
||||
it.each(['not json', 'null', '[]', '1'])('rejects a non-message payload: %s', (text) => {
|
||||
expect(() => parseRemoteStreamServerMessage(text)).toThrow('api gateway: Remote stream message')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { once } from 'node:events'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import {
|
||||
RemoteStreamMuxServer,
|
||||
type RemoteStreamFailureMapper,
|
||||
type RemoteStreamOpener,
|
||||
} from '../src/stream-server.ts'
|
||||
|
||||
interface RunningMux {
|
||||
readonly http: Server
|
||||
readonly mux: RemoteStreamMuxServer
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
const running = new Set<RunningMux>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...running].map(async (entry) => {
|
||||
running.delete(entry)
|
||||
await entry.mux.close().catch(() => undefined)
|
||||
await closeHttp(entry.http)
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Remote stream mux server carrier lifecycle', () => {
|
||||
it('rejects binary, malformed, and duplicate logical-stream messages', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
|
||||
|
||||
const binary = await connect(entry.url)
|
||||
const binaryClosed = once(binary, 'close')
|
||||
binary.send(Buffer.from('{}'))
|
||||
const binaryEvent = await binaryClosed
|
||||
expect(binaryEvent[0]).toBe(1003)
|
||||
|
||||
const malformed = await connect(entry.url)
|
||||
const malformedClosed = once(malformed, 'close')
|
||||
malformed.send('not json')
|
||||
const malformedEvent = await malformedClosed
|
||||
expect(malformedEvent[0]).toBe(1008)
|
||||
expect(String(malformedEvent[1])).toBe('invalid Remote stream request')
|
||||
|
||||
const duplicate = await connect(entry.url)
|
||||
const longId = 'same'.repeat(100)
|
||||
duplicate.send(openFrame(longId))
|
||||
duplicate.send(openFrame(longId))
|
||||
const duplicateEvent = await once(duplicate, 'close')
|
||||
expect(duplicateEvent[0]).toBe(1008)
|
||||
expect(String(duplicateEvent[1])).toBe('invalid Remote stream request')
|
||||
|
||||
const noInput = await connect(entry.url)
|
||||
noInput.send(openFrame('no-input'))
|
||||
noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' }))
|
||||
const noInputEvent = await once(noInput, 'close')
|
||||
expect(noInputEvent[0]).toBe(1008)
|
||||
expect(String(noInputEvent[1])).toBe('invalid Remote stream request')
|
||||
})
|
||||
|
||||
it('accepts all ws text representations and terminates a carrier error', async () => {
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal))
|
||||
const client = await connect(entry.url)
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' })
|
||||
|
||||
serverSocket.emit('message', [Buffer.from(cancel)], false)
|
||||
serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false)
|
||||
|
||||
const closed = once(client, 'close')
|
||||
serverSocket.emit('error', new Error('fixture carrier failure'))
|
||||
await closed
|
||||
})
|
||||
|
||||
it('does not send an end frame after clean source cancellation', async () => {
|
||||
let opened!: () => void
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
let returned!: () => void
|
||||
const didReturn = new Promise<void>((resolve) => { returned = resolve })
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => {
|
||||
opened()
|
||||
return cleanlyCancelled(signal, returned)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
const frames: unknown[] = []
|
||||
client.on('message', (data) => {
|
||||
if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame')
|
||||
frames.push(JSON.parse(data.toString('utf8')) as unknown)
|
||||
})
|
||||
client.send(openFrame('cancelled'))
|
||||
await didOpen
|
||||
client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' }))
|
||||
await didReturn
|
||||
await new Promise<void>((resolve) => { setImmediate(resolve) })
|
||||
expect(frames).toEqual([])
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
})
|
||||
|
||||
it('closes the carrier when ws reports an item write failure', async () => {
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => { release = resolve })
|
||||
let opened!: () => void
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
const entry = await startMux(async () => delayedItem(released, opened))
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('write-failure'))
|
||||
await didOpen
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
const mutable = serverSocket as unknown as {
|
||||
send(data: unknown, callback: (error?: Error) => void): void
|
||||
}
|
||||
mutable.send = (_data, callback): void => {
|
||||
callback(new Error('fixture ws write failure'))
|
||||
}
|
||||
|
||||
const closed = once(client, 'close')
|
||||
release()
|
||||
const closeEvent = await closed
|
||||
expect(closeEvent[0]).toBe(1011)
|
||||
expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
|
||||
})
|
||||
|
||||
it('contains an item produced after its socket closes', async () => {
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => { release = resolve })
|
||||
let opened!: () => void
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
let returned!: () => void
|
||||
const didReturn = new Promise<void>((resolve) => { returned = resolve })
|
||||
const entry = await startMux(async () => delayedItem(released, opened, returned))
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('late-item'))
|
||||
await didOpen
|
||||
const serverSocket = acceptedSocket(entry.mux)
|
||||
client.close()
|
||||
await once(client, 'close')
|
||||
await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) })
|
||||
release()
|
||||
await didReturn
|
||||
})
|
||||
|
||||
it('terminates active sockets on close and reports a repeated close', async () => {
|
||||
let opened!: () => void
|
||||
const didOpen = new Promise<void>((resolve) => { opened = resolve })
|
||||
let returned!: () => void
|
||||
const didReturn = new Promise<void>((resolve) => { returned = resolve })
|
||||
const entry = await startMux(async (_endpoint, _payload, signal) => {
|
||||
opened()
|
||||
return cleanlyCancelled(signal, returned)
|
||||
})
|
||||
const client = await connect(entry.url)
|
||||
client.send(openFrame('active'))
|
||||
await didOpen
|
||||
|
||||
const closed = once(client, 'close')
|
||||
await entry.mux.close()
|
||||
running.delete(entry)
|
||||
await closed
|
||||
await didReturn
|
||||
await expect(entry.mux.close()).rejects.toThrow()
|
||||
await closeHttp(entry.http)
|
||||
})
|
||||
})
|
||||
|
||||
const mapFailure: RemoteStreamFailureMapper = error => ({
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
})
|
||||
|
||||
async function startMux(open: RemoteStreamOpener): Promise<RunningMux> {
|
||||
const mux = new RemoteStreamMuxServer(open, mapFailure)
|
||||
const http = createServer()
|
||||
http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
http.once('error', reject)
|
||||
http.listen(0, '127.0.0.1', () => {
|
||||
http.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port')
|
||||
const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` }
|
||||
running.add(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function connect(url: string): Promise<WebSocket> {
|
||||
const socket = new WebSocket(url)
|
||||
await once(socket, 'open')
|
||||
return socket
|
||||
}
|
||||
|
||||
function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket {
|
||||
const exposed = mux as unknown as { server: { clients: Set<WebSocket> } }
|
||||
const socket = [...exposed.server.clients][0]
|
||||
if (socket === undefined) throw new Error('fixture mux has no accepted socket')
|
||||
return socket
|
||||
}
|
||||
|
||||
function openFrame(streamId: string): string {
|
||||
return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} })
|
||||
}
|
||||
|
||||
async function *waitForAbort(signal: AbortSignal): AsyncIterable<never> {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable<never> {
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
} finally {
|
||||
returned()
|
||||
}
|
||||
}
|
||||
|
||||
async function *delayedItem(
|
||||
released: Promise<void>,
|
||||
opened: () => void,
|
||||
returned: () => void = () => {},
|
||||
): AsyncIterable<string> {
|
||||
try {
|
||||
opened()
|
||||
await released
|
||||
yield 'item'
|
||||
} finally {
|
||||
returned()
|
||||
}
|
||||
}
|
||||
|
||||
async function closeHttp(server: Server): Promise<void> {
|
||||
if (!server.listening) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from './runtime-types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
|
||||
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
|
||||
type Params<F> = F extends (...args: infer P) => unknown ? P : never
|
||||
|
||||
@@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Agent, AgentOptions } from './runtime-types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
import type { AgentOptions } from './runtime-types.ts'
|
||||
|
||||
export * from './runtime-types.ts'
|
||||
export * from './types.ts'
|
||||
@@ -23,16 +23,6 @@ export * from './model-selection.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertLookupMap {
|
||||
agent: TypertLookup<Agent, SessionId>
|
||||
}
|
||||
|
||||
interface TypertContextMap {
|
||||
agent: TypertContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
@@ -276,6 +266,7 @@ export class AgentRegistry extends Service {
|
||||
typeCtx.typert.contexts.registerHost('agent', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
identity: candidate => candidate.agent?.id,
|
||||
resolve: sessionId => this.get(sessionId)?.ctx,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import type { InboxTarget } from './types.ts'
|
||||
import type { Agent } from './types.ts'
|
||||
export type { Agent } from './types.ts'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -60,39 +61,38 @@ export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Public live-agent handle. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
/** The provider route and model this agent's requests use. */
|
||||
readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
readonly session: Session
|
||||
/** The agent-owned projection of durable pending work. */
|
||||
readonly inbox: Inbox
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
declare module './types.ts' {
|
||||
/** Public live-agent handle. */
|
||||
interface Agent {
|
||||
/** The provider route and model this agent's requests use. */
|
||||
readonly options: AgentOptions
|
||||
/** The live session this agent drives; its log is the durable source of truth. */
|
||||
readonly session: Session
|
||||
/** The agent-owned projection of durable pending work. */
|
||||
readonly inbox: Inbox
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn or between-turn task. The first cause wins for that activity. With no
|
||||
* active activity, cancellation is a no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the active operation signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Resolve after the current whole-agent activity reaches quiescence. This
|
||||
* follows replacement work started before the observed driver retires,
|
||||
* but does not identify the settlement of any particular message.
|
||||
* @returns fulfillment after no active driver or maintenance task remains.
|
||||
*/
|
||||
whenIdle(): Promise<void>
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
/**
|
||||
* Run one non-turn maintenance task from the true idle phase. The task starts
|
||||
* synchronously after claiming that phase; later waking input remains in the
|
||||
* inbox until the task settles, while public status stays `idle`.
|
||||
@@ -101,9 +101,9 @@ export interface Agent {
|
||||
* @throws synchronously when turn-driving or another maintenance task already owns the agent.
|
||||
* @returns the task promise.
|
||||
*/
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
|
||||
|
||||
/**
|
||||
/**
|
||||
* Route identified input to an inbox boundary and optionally wake the driver.
|
||||
* Waking input submitted after active cancellation is queued for the next
|
||||
* turn and runs when the aborted activity converges to idle; a `disposed`
|
||||
@@ -114,25 +114,25 @@ export interface Agent {
|
||||
* @param target - the preferred next-turn or next-step inbox boundary.
|
||||
* @param wakeup - whether delivery may wake the driver.
|
||||
*/
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
|
||||
* sole ordinary message of its own turn.
|
||||
* @param message - identified prompt content and the source that supplied it.
|
||||
*/
|
||||
followup(message: UserMessage): void
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Submit steering for the nearest step. An idle driver starts a turn;
|
||||
* a running driver consumes it at its next step boundary.
|
||||
* A rejected step leaves steering parked in the inbox until the next
|
||||
* wake; cancellation or disposal may discard pending steering.
|
||||
* @param message - identified steering content and the source that supplied it.
|
||||
*/
|
||||
steer(message: UserMessage): void
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
/**
|
||||
* Queue model-facing context for the next pre-step without waking the
|
||||
* driver. A running driver claims it at the nearest later step boundary;
|
||||
* idle drivers leave it pending until follow-up or steering
|
||||
@@ -140,7 +140,8 @@ export interface Agent {
|
||||
* batch. Cancellation or disposal may discard pending context.
|
||||
* @param message - identified injected context and the source that supplied it.
|
||||
*/
|
||||
inject(message: UserMessage): void
|
||||
inject(message: UserMessage): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
*/
|
||||
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Minimum Agent identity visible to cross-process event declarations. */
|
||||
export interface Agent {
|
||||
/** Session-backed Agent identity. */
|
||||
readonly id: SessionId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertLookupMap {
|
||||
agent: TypertLookup<Agent, SessionId>
|
||||
}
|
||||
|
||||
interface TypertContextMap {
|
||||
/** Agent Context identity shared by Host and Client adapters. */
|
||||
agent: TypertContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/** One of the two ordered pending-message lists owned by an agent. */
|
||||
export type InboxTarget = 'next-turn' | 'next-step'
|
||||
|
||||
@@ -149,6 +149,7 @@ describe('AgentRegistry', () => {
|
||||
await agentFiber
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const agent = stubAgent('remote-agent')
|
||||
Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) })
|
||||
const disposeAgent = ctx.agents.register(agent)
|
||||
|
||||
const lookup = ctx.typert.lookups.get('agent')
|
||||
@@ -159,7 +160,10 @@ describe('AgentRegistry', () => {
|
||||
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
||||
})
|
||||
expect(lookup?.resolve(agent.id)).toBe(agent)
|
||||
expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx)
|
||||
const context = ctx.typert.contexts.getHost('agent')
|
||||
expect(context?.identity(agent.ctx)).toBe(agent.id)
|
||||
expect(context?.identity(ctx)).toBeUndefined()
|
||||
expect(context?.resolve(agent.id)).toBe(agent.ctx)
|
||||
|
||||
disposeAgent()
|
||||
expect(lookup?.resolve(agent.id)).toBeUndefined()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Page-side API carrier over the postMessage tunnel. Only `doFetch` is
|
||||
* implemented: the streaming methods stay on `AbstractApiClient`'s default
|
||||
* `readSse`, which is exactly what the worker answers on the two event-stream
|
||||
* paths — so unary calls and downstream streams share one framing and neither
|
||||
* side needs a WebSocket.
|
||||
* Page-side unary API carrier over the postMessage tunnel. Gateway Remote
|
||||
* streams use the tunnel's dedicated logical-stream frames instead of this
|
||||
* fetch-shaped API path.
|
||||
*/
|
||||
import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
import type { WorkerTunnel } from './client.ts'
|
||||
|
||||
@@ -6,31 +6,16 @@
|
||||
*/
|
||||
|
||||
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
/** Frame sent to the worker. */
|
||||
interface RequestFrame {
|
||||
t: 'req'
|
||||
id: number
|
||||
method: string
|
||||
/** Absolute URL; the worker derives `req.url` (pathname + search) from it. */
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body?: ArrayBuffer | undefined
|
||||
}
|
||||
|
||||
/** Cancellation of an in-flight request or stream. */
|
||||
interface AbortFrame {
|
||||
t: 'abort'
|
||||
id: number
|
||||
}
|
||||
|
||||
/** Frames received from the worker. */
|
||||
type ResponseFrame =
|
||||
| { t: 'res'; id: number; status: number; headers: Record<string, string>; body?: ArrayBuffer; message?: string }
|
||||
| { t: 'res-head'; id: number; status: number; headers: Record<string, string> }
|
||||
| { t: 'res-chunk'; id: number; chunk: ArrayBuffer }
|
||||
| { t: 'res-end'; id: number }
|
||||
| { t: 'res-err'; id: number; message: string }
|
||||
import type {
|
||||
TunnelAbortFrame as AbortFrame,
|
||||
TunnelOutboundFrame as ResponseFrame,
|
||||
TunnelRequestFrame as RequestFrame,
|
||||
TunnelRequestId,
|
||||
TunnelStreamEndFrame,
|
||||
TunnelStreamErrorFrame,
|
||||
TunnelStreamItemFrame,
|
||||
TunnelStreamOpenFrame,
|
||||
} from '../transport/frames.ts'
|
||||
|
||||
/** Boot payload of the tunnel bootstrap route. */
|
||||
export interface BootPayload {
|
||||
@@ -46,6 +31,58 @@ interface PendingUnary {
|
||||
reject(reason: Error): void
|
||||
}
|
||||
|
||||
type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame
|
||||
|
||||
interface TunnelStreamFailureMarker {
|
||||
readonly kind: 'remote' | 'carrier'
|
||||
readonly code?: string
|
||||
readonly details?: object
|
||||
}
|
||||
|
||||
/** Error carrying stream semantics across independently bundled Client code. */
|
||||
class TunnelLogicalStreamError extends Error {
|
||||
readonly dshRemoteStreamFailure: TunnelStreamFailureMarker
|
||||
|
||||
constructor(failure: TunnelStreamErrorFrame['failure'], options?: ErrorOptions) {
|
||||
super(failure.message, options)
|
||||
this.name = 'TunnelLogicalStreamError'
|
||||
this.dshRemoteStreamFailure = failure.kind === 'remote'
|
||||
? { kind: 'remote', code: failure.code, details: failure.details }
|
||||
: { kind: 'carrier' }
|
||||
}
|
||||
}
|
||||
|
||||
class LogicalStreamInbox {
|
||||
private readonly frames: LogicalStreamFrame[] = []
|
||||
private wake: (() => void) | undefined
|
||||
private failed = false
|
||||
private failure: unknown
|
||||
|
||||
push(frame: LogicalStreamFrame): void {
|
||||
if (this.failed) return
|
||||
this.frames.push(frame)
|
||||
this.wake?.()
|
||||
this.wake = undefined
|
||||
}
|
||||
|
||||
fail(reason: unknown): void {
|
||||
if (this.failed) return
|
||||
this.failed = true
|
||||
this.failure = reason
|
||||
this.frames.length = 0
|
||||
this.wake?.()
|
||||
this.wake = undefined
|
||||
}
|
||||
|
||||
async next(): Promise<LogicalStreamFrame> {
|
||||
while (this.frames.length === 0) {
|
||||
if (this.failed) throw this.failure
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
return this.frames.shift() as LogicalStreamFrame
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses the worker only produces when the host refused the exchange rather than
|
||||
* answered it; a route's own 4xx is the tree talking and stays silent here.
|
||||
@@ -72,8 +109,9 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304])
|
||||
export class WorkerTunnel {
|
||||
private readonly worker: Worker
|
||||
private nextId = 1
|
||||
private readonly unary = new Map<number, PendingUnary>()
|
||||
private readonly streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
|
||||
private readonly unary = new Map<TunnelRequestId, PendingUnary>()
|
||||
private readonly bodyStreams = new Map<TunnelRequestId, ReadableStreamDefaultController<Uint8Array>>()
|
||||
private readonly logicalStreams = new Map<TunnelRequestId, LogicalStreamInbox>()
|
||||
/**
|
||||
* In-flight request descriptions, so a refusal names what was refused.
|
||||
*
|
||||
@@ -82,10 +120,10 @@ export class WorkerTunnel {
|
||||
* page console but not the frames. Warning here separates the two without
|
||||
* recording anything on the normal path, where no refusal frame ever arrives.
|
||||
*/
|
||||
private readonly inFlight = new Map<number, string>()
|
||||
private readonly inFlight = new Map<TunnelRequestId, string>()
|
||||
|
||||
/** Body-phase abort listeners, released when their stream settles. */
|
||||
private readonly releases = new Map<number, () => void>()
|
||||
private readonly releases = new Map<TunnelRequestId, () => void>()
|
||||
|
||||
/**
|
||||
* Attach to a spawned worker and start consuming response frames.
|
||||
@@ -102,8 +140,14 @@ export class WorkerTunnel {
|
||||
this.inFlight.clear()
|
||||
for (const pending of this.unary.values()) pending.reject(reason)
|
||||
this.unary.clear()
|
||||
for (const controller of this.streams.values()) controller.error(reason)
|
||||
this.streams.clear()
|
||||
for (const controller of this.bodyStreams.values()) controller.error(reason)
|
||||
this.bodyStreams.clear()
|
||||
const failure = new TunnelLogicalStreamError({
|
||||
kind: 'carrier',
|
||||
message: `web-preview tunnel: worker failed: ${event.message}`,
|
||||
}, { cause: reason })
|
||||
for (const inbox of this.logicalStreams.values()) inbox.fail(failure)
|
||||
this.logicalStreams.clear()
|
||||
for (const release of this.releases.values()) release()
|
||||
this.releases.clear()
|
||||
})
|
||||
@@ -145,13 +189,60 @@ export class WorkerTunnel {
|
||||
const settled = await Promise.race([response, raced.rejected])
|
||||
// A streaming response outlives its head: hand the signal to the body
|
||||
// phase, so a later stop still ends the stream and reaches the worker.
|
||||
if (this.streams.has(id)) this.observeStreamAbort(id, signal)
|
||||
if (this.bodyStreams.has(id)) this.observeStreamAbort(id, signal)
|
||||
return settled
|
||||
} finally {
|
||||
raced.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one decoded Gateway Remote stream over the worker-local carrier.
|
||||
* @param endpoint - canonical Gateway Remote endpoint.
|
||||
* @param payload - decoded endpoint payload.
|
||||
* @param signal - logical-stream cancellation.
|
||||
* @returns decoded stream values from the worker Host.
|
||||
*/
|
||||
async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator {
|
||||
signal.throwIfAborted()
|
||||
const id = this.nextId++
|
||||
const inbox = new LogicalStreamInbox()
|
||||
let opened = false
|
||||
let terminal = false
|
||||
const onAbort = (): void => { inbox.fail(signal.reason) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.logicalStreams.set(id, inbox)
|
||||
this.inFlight.set(id, `STREAM ${endpoint}`)
|
||||
try {
|
||||
const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload }
|
||||
try {
|
||||
this.worker.postMessage(frame)
|
||||
opened = true
|
||||
} catch (cause) {
|
||||
throw new TunnelLogicalStreamError({
|
||||
kind: 'carrier',
|
||||
message: `web-preview tunnel: failed to open Remote stream ${endpoint}`,
|
||||
}, { cause })
|
||||
}
|
||||
while (true) {
|
||||
const response = await inbox.next()
|
||||
signal.throwIfAborted()
|
||||
if (response.t === 'stream-item') {
|
||||
yield response.value
|
||||
continue
|
||||
}
|
||||
terminal = true
|
||||
if (response.t === 'stream-error') throw new TunnelLogicalStreamError(response.failure)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
this.logicalStreams.delete(id)
|
||||
this.inFlight.delete(id)
|
||||
if (opened && !terminal) this.abortWorkerOperation(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pre-cordis boot payload (the injection table).
|
||||
* @returns The payload the page applies before the client tree loads.
|
||||
@@ -198,7 +289,7 @@ export class WorkerTunnel {
|
||||
}
|
||||
}
|
||||
|
||||
private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
|
||||
private rejectOnAbort(id: TunnelRequestId, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
|
||||
let release = (): void => {}
|
||||
const rejected = new Promise<never>((_resolve, reject) => {
|
||||
const fail = (): void => { reject(this.abortRequest(id)) }
|
||||
@@ -220,14 +311,13 @@ export class WorkerTunnel {
|
||||
* @param id - request id being abandoned.
|
||||
* @returns The abort error the caller surfaces.
|
||||
*/
|
||||
private abortRequest(id: number): DOMException {
|
||||
private abortRequest(id: TunnelRequestId): DOMException {
|
||||
this.unary.delete(id)
|
||||
const controller = this.streams.get(id)
|
||||
this.streams.delete(id)
|
||||
const controller = this.bodyStreams.get(id)
|
||||
this.bodyStreams.delete(id)
|
||||
this.inFlight.delete(id)
|
||||
this.releases.delete(id)
|
||||
const abort: AbortFrame = { t: 'abort', id }
|
||||
this.worker.postMessage(abort)
|
||||
this.abortWorkerOperation(id)
|
||||
const reason = new DOMException('The operation was aborted.', 'AbortError')
|
||||
controller?.error(reason)
|
||||
return reason
|
||||
@@ -240,26 +330,35 @@ export class WorkerTunnel {
|
||||
* @param id - request id whose body is still crossing.
|
||||
* @param signal - the caller's signal.
|
||||
*/
|
||||
private observeStreamAbort(id: number, signal: AbortSignal): void {
|
||||
private observeStreamAbort(id: TunnelRequestId, signal: AbortSignal): void {
|
||||
const onAbort = (): void => { this.abortRequest(id) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
|
||||
}
|
||||
|
||||
/** Release a body-phase abort listener a settled stream no longer needs. */
|
||||
private releaseSignal(id: number): void {
|
||||
private releaseSignal(id: TunnelRequestId): void {
|
||||
const release = this.releases.get(id)
|
||||
this.releases.delete(id)
|
||||
release?.()
|
||||
}
|
||||
|
||||
/** Cancel a stream the consumer stopped reading (the head already resolved). */
|
||||
private cancelStream(id: number): void {
|
||||
private cancelStream(id: TunnelRequestId): void {
|
||||
this.releaseSignal(id)
|
||||
this.streams.delete(id)
|
||||
this.bodyStreams.delete(id)
|
||||
this.inFlight.delete(id)
|
||||
this.abortWorkerOperation(id)
|
||||
}
|
||||
|
||||
/** Best-effort cancellation: a failed worker cannot receive the frame anyway. */
|
||||
private abortWorkerOperation(id: TunnelRequestId): void {
|
||||
const abort: AbortFrame = { t: 'abort', id }
|
||||
this.worker.postMessage(abort)
|
||||
try {
|
||||
this.worker.postMessage(abort)
|
||||
} catch {
|
||||
// The operation is already locally terminal; worker failure is reported by its owning path.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,7 +370,7 @@ export class WorkerTunnel {
|
||||
* @param id - request id the frame answers.
|
||||
* @param outcome - what came back instead of a reply.
|
||||
*/
|
||||
private warnRefusal(id: number, outcome: string): void {
|
||||
private warnRefusal(id: TunnelRequestId, outcome: string): void {
|
||||
console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`)
|
||||
}
|
||||
|
||||
@@ -297,7 +396,7 @@ export class WorkerTunnel {
|
||||
this.unary.delete(frame.id)
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
this.streams.set(frame.id, controller)
|
||||
this.bodyStreams.set(frame.id, controller)
|
||||
},
|
||||
cancel: () => {
|
||||
this.cancelStream(frame.id)
|
||||
@@ -307,13 +406,13 @@ export class WorkerTunnel {
|
||||
return
|
||||
}
|
||||
case 'res-chunk': {
|
||||
this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
|
||||
this.bodyStreams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
|
||||
return
|
||||
}
|
||||
case 'res-end': {
|
||||
const controller = this.streams.get(frame.id)
|
||||
const controller = this.bodyStreams.get(frame.id)
|
||||
if (controller === undefined) return
|
||||
this.streams.delete(frame.id)
|
||||
this.bodyStreams.delete(frame.id)
|
||||
this.inFlight.delete(frame.id)
|
||||
this.releaseSignal(frame.id)
|
||||
controller.close()
|
||||
@@ -329,13 +428,19 @@ export class WorkerTunnel {
|
||||
pending.reject(reason)
|
||||
return
|
||||
}
|
||||
const controller = this.streams.get(frame.id)
|
||||
const controller = this.bodyStreams.get(frame.id)
|
||||
if (controller === undefined) return
|
||||
this.streams.delete(frame.id)
|
||||
this.bodyStreams.delete(frame.id)
|
||||
this.releaseSignal(frame.id)
|
||||
controller.error(reason)
|
||||
return
|
||||
}
|
||||
case 'stream-item':
|
||||
case 'stream-end':
|
||||
case 'stream-error': {
|
||||
this.logicalStreams.get(frame.id)?.push(frame)
|
||||
return
|
||||
}
|
||||
default: {
|
||||
const unknown: never = frame
|
||||
throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`)
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ClientTransportGlobal {
|
||||
__DSH_TRANSPORT__?: {
|
||||
createApiClient: () => WorkerApiClient
|
||||
fetch: TunnelFetch
|
||||
openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>
|
||||
loadBundle: (url: string) => Promise<void>
|
||||
/** The page spawned the worker the Host runs in, so the page owns it. */
|
||||
ownsHost: boolean
|
||||
@@ -84,6 +85,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
|
||||
;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
|
||||
createApiClient: () => new WorkerApiClient(tunnel),
|
||||
fetch: (input, init) => tunnel.fetch(input, init),
|
||||
openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal),
|
||||
loadBundle: (url: string) => tunnel.loadBundle(url),
|
||||
// The host lives in a worker this page spawned: the page owns it, so
|
||||
// the privileged surface stays reachable off loopback authorities.
|
||||
|
||||
@@ -11,6 +11,8 @@ export {
|
||||
type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame,
|
||||
type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
|
||||
type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
|
||||
type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame,
|
||||
type TunnelStreamOpenFrame,
|
||||
} from './transport/frames.ts'
|
||||
export {
|
||||
DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
|
||||
@@ -23,7 +25,7 @@ export {
|
||||
} from './transport/synthetic-http.ts'
|
||||
export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
|
||||
export {
|
||||
API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer,
|
||||
API_PREFIX, SYNTHETIC_HOST, TunnelServer,
|
||||
type TunnelPort, type TunnelSeams, type TunnelServerOptions,
|
||||
} from './transport/tunnel.ts'
|
||||
export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts'
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface TunnelRequestFrame {
|
||||
readonly body?: ArrayBuffer | undefined
|
||||
}
|
||||
|
||||
/** Open one Gateway Remote stream over the worker-local carrier. */
|
||||
export interface TunnelStreamOpenFrame {
|
||||
readonly t: 'stream-open'
|
||||
readonly id: TunnelRequestId
|
||||
readonly endpoint: string
|
||||
readonly payload: unknown
|
||||
}
|
||||
|
||||
/** Page-side cancellation of an in-flight request or stream. */
|
||||
export interface TunnelAbortFrame {
|
||||
readonly t: 'abort'
|
||||
@@ -34,7 +42,11 @@ export interface TunnelInitFrame {
|
||||
}
|
||||
|
||||
/** Every frame the page sends the worker. */
|
||||
export type TunnelInboundFrame = TunnelInitFrame | TunnelRequestFrame | TunnelAbortFrame
|
||||
export type TunnelInboundFrame =
|
||||
| TunnelInitFrame
|
||||
| TunnelRequestFrame
|
||||
| TunnelStreamOpenFrame
|
||||
| TunnelAbortFrame
|
||||
|
||||
/** Complete response for unary requests and static files. */
|
||||
export interface TunnelResponseFrame {
|
||||
@@ -75,6 +87,36 @@ export interface TunnelResponseErrorFrame {
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** One decoded value from a worker-local Gateway Remote stream. */
|
||||
export interface TunnelStreamItemFrame {
|
||||
readonly t: 'stream-item'
|
||||
readonly id: TunnelRequestId
|
||||
readonly value?: unknown
|
||||
}
|
||||
|
||||
/** Normal completion of a worker-local Gateway Remote stream. */
|
||||
export interface TunnelStreamEndFrame {
|
||||
readonly t: 'stream-end'
|
||||
readonly id: TunnelRequestId
|
||||
}
|
||||
|
||||
/** Stable Host failure or worker-carrier failure for one logical stream. */
|
||||
export interface TunnelStreamErrorFrame {
|
||||
readonly t: 'stream-error'
|
||||
readonly id: TunnelRequestId
|
||||
readonly failure:
|
||||
| {
|
||||
readonly kind: 'remote'
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
| {
|
||||
readonly kind: 'carrier'
|
||||
readonly message: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Frames the worker emits. */
|
||||
export type TunnelOutboundFrame =
|
||||
| TunnelResponseFrame
|
||||
@@ -82,6 +124,9 @@ export type TunnelOutboundFrame =
|
||||
| TunnelResponseChunkFrame
|
||||
| TunnelResponseEndFrame
|
||||
| TunnelResponseErrorFrame
|
||||
| TunnelStreamItemFrame
|
||||
| TunnelStreamEndFrame
|
||||
| TunnelStreamErrorFrame
|
||||
|
||||
/**
|
||||
* Validate a `postMessage` payload as a tunnel frame.
|
||||
@@ -104,6 +149,12 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame {
|
||||
throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`)
|
||||
}
|
||||
if (frame.t === 'abort') return { t: 'abort', id }
|
||||
if (frame.t === 'stream-open') {
|
||||
if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) {
|
||||
throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`)
|
||||
}
|
||||
return { t: 'stream-open', id, endpoint: frame.endpoint, payload: frame.payload }
|
||||
}
|
||||
if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`)
|
||||
if (typeof frame.method !== 'string' || typeof frame.url !== 'string') {
|
||||
throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`)
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*
|
||||
* - `GET /__boot__` answers from tunnel glue, never from the host API surface,
|
||||
* because the page needs the boot payload before its Cordis tree exists.
|
||||
* - The two event-stream paths go straight to the API fetch handler so they take
|
||||
* its SSE branch; the `/api` route answers 426 upgrade-required first.
|
||||
* - Privileged `/api` methods take that same direct entry: the browser strips the
|
||||
* `host` header from the WHATWG `Request` the route lane rebuilds, so the
|
||||
* privileged fence would answer 403 for every one of them. The method set is
|
||||
@@ -21,14 +19,12 @@
|
||||
*/
|
||||
import {
|
||||
parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId,
|
||||
type TunnelStreamOpenFrame,
|
||||
} from './frames.ts'
|
||||
import {
|
||||
createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange,
|
||||
} from './synthetic-http.ts'
|
||||
|
||||
/** Event-stream routes that must reach the SSE branch of the API fetch handler. */
|
||||
export const STREAM_PATHS = ['/api/events.mux', '/api/events.host'] as const
|
||||
|
||||
/** Prefix owning the API methods. */
|
||||
export const API_PREFIX = '/api'
|
||||
|
||||
@@ -91,12 +87,24 @@ export interface TunnelPort {
|
||||
/** What the tunnel gains once the host tree is up. */
|
||||
export interface TunnelSeams {
|
||||
/**
|
||||
* Direct entry to the API fetch handler: event streams, privileged methods,
|
||||
* and any unary call the route lane refused with 403.
|
||||
* Direct entry to the API fetch handler for privileged methods and any unary
|
||||
* call the route lane refused with 403.
|
||||
*/
|
||||
readonly directFetch: (request: Request) => Promise<Response>
|
||||
/** Boot payload for `GET /__boot__`: the structured index injection table. */
|
||||
readonly bootPayload: () => unknown
|
||||
/** Open one decoded Gateway Remote stream without another network carrier. */
|
||||
readonly openStream: (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
/** Convert a Gateway stream failure to stable Client fields. */
|
||||
readonly streamFailure: (error: unknown) => {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
readonly details: object
|
||||
}
|
||||
}
|
||||
|
||||
/** Construction inputs for {@link TunnelServer}. */
|
||||
@@ -125,6 +133,8 @@ interface InFlight {
|
||||
abort(): void
|
||||
}
|
||||
|
||||
type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame
|
||||
|
||||
/** Recorded response frames, so a 403 from the route lane can be discarded. */
|
||||
class BufferedSink {
|
||||
private readonly calls: Array<() => void> = []
|
||||
@@ -171,7 +181,7 @@ export class TunnelServer {
|
||||
private readonly requestListener: () => Promise<RequestListener>
|
||||
private readonly privilegedMethods: ReadonlySet<string> | undefined
|
||||
private readonly unaryApiLane: 'route' | 'direct'
|
||||
private readonly queue: TunnelRequestFrame[] = []
|
||||
private readonly queue: QueuedFrame[] = []
|
||||
private readonly inFlight = new Map<TunnelRequestId, InFlight>()
|
||||
private seams: TunnelSeams | undefined
|
||||
private failure: string | undefined
|
||||
@@ -209,7 +219,7 @@ export class TunnelServer {
|
||||
this.queue.push(frame)
|
||||
return
|
||||
}
|
||||
void this.serveRequest(frame)
|
||||
this.dispatchFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +229,7 @@ export class TunnelServer {
|
||||
serve(seams: TunnelSeams): void {
|
||||
this.seams = seams
|
||||
console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`)
|
||||
for (const frame of this.queue.splice(0)) void this.serveRequest(frame)
|
||||
for (const frame of this.queue.splice(0)) this.dispatchFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,7 +247,15 @@ export class TunnelServer {
|
||||
this.port.postMessage(frame, transfer)
|
||||
}
|
||||
|
||||
private refuse(frame: TunnelRequestFrame, message: string): void {
|
||||
private refuse(frame: QueuedFrame, message: string): void {
|
||||
if (frame.t === 'stream-open') {
|
||||
this.send({
|
||||
t: 'stream-error',
|
||||
id: frame.id,
|
||||
failure: { kind: 'carrier', message },
|
||||
})
|
||||
return
|
||||
}
|
||||
const body = toTransferable(encoder.encode(message))
|
||||
this.send({
|
||||
t: 'res',
|
||||
@@ -249,6 +267,40 @@ export class TunnelServer {
|
||||
}, [body])
|
||||
}
|
||||
|
||||
private dispatchFrame(frame: QueuedFrame): void {
|
||||
if (frame.t === 'stream-open') void this.serveStream(frame)
|
||||
else void this.serveRequest(frame)
|
||||
}
|
||||
|
||||
private async serveStream(frame: TunnelStreamOpenFrame): Promise<void> {
|
||||
if (this.seams === undefined) {
|
||||
this.refuse(frame, 'webworker tunnel: Remote stream requested before the host tree is serving')
|
||||
return
|
||||
}
|
||||
const seams = this.seams
|
||||
const controller = new AbortController()
|
||||
this.inFlight.set(frame.id, { abort: () => { controller.abort() } })
|
||||
try {
|
||||
const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal)
|
||||
for await (const value of source) {
|
||||
if (controller.signal.aborted) return
|
||||
this.send({ t: 'stream-item', id: frame.id, value })
|
||||
}
|
||||
if (!controller.signal.aborted) this.send({ t: 'stream-end', id: frame.id })
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const failure = seams.streamFailure(error)
|
||||
this.send({
|
||||
t: 'stream-error',
|
||||
id: frame.id,
|
||||
failure: { kind: 'remote', ...failure },
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.inFlight.delete(frame.id)
|
||||
}
|
||||
}
|
||||
|
||||
private sinkFor(id: TunnelRequestId): ResponseSink {
|
||||
const send = this.send.bind(this)
|
||||
const inFlight = this.inFlight
|
||||
@@ -291,7 +343,6 @@ export class TunnelServer {
|
||||
try {
|
||||
const { frame: routed, path } = this.pathFrame(frame)
|
||||
if (path === '/__boot__') { this.serveBoot(frame, sink); return }
|
||||
if ((STREAM_PATHS as readonly string[]).includes(path)) { await this.serveDirect(frame, sink); return }
|
||||
if (path.startsWith(`${API_PREFIX}/`)) { await this.serveApi(frame, routed, path, sink); return }
|
||||
this.dispatch(routed, sink)
|
||||
} catch (reason) {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host
|
||||
*/
|
||||
import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
|
||||
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
||||
import type { AlsCausality } from './polyfill/async-context/als-runtime.ts'
|
||||
import { dirname, join } from './module-system/posix-path.ts'
|
||||
import { installProcessGlobal } from './node/globals/process.ts'
|
||||
@@ -237,6 +238,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
||||
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
|
||||
const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined
|
||||
if (typertGateway === undefined) {
|
||||
throw new Error('webworker host: the tree activated without a typertGateway service')
|
||||
}
|
||||
const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as {
|
||||
toFetchHandler: (api: unknown) => { fetch(request: Request): Promise<Response> }
|
||||
}
|
||||
@@ -248,6 +253,8 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
||||
tunnel.serve({
|
||||
directFetch: (request: Request) => handler.fetch(request),
|
||||
bootPayload: () => readBootPayload(ctx),
|
||||
openStream: typertGateway.wireStream.open,
|
||||
streamFailure: typertGateway.wireStream.failure,
|
||||
})
|
||||
} catch (reason) {
|
||||
tunnel.fail(reason)
|
||||
|
||||
@@ -27,17 +27,31 @@ const warnings: string[] = []
|
||||
console.warn = (message: string) => { warnings.push(message) }
|
||||
;(globalThis as { location?: unknown }).location = { origin: 'http://localhost:4173' }
|
||||
|
||||
type Listener = (event: { data: unknown }) => void
|
||||
type StubListener = (event: { data?: unknown; message?: string }) => void
|
||||
|
||||
/** A worker stand-in: collects what the page sent, replays what the test delivers. */
|
||||
function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deliver: (frame: unknown) => void } {
|
||||
const listeners: Listener[] = []
|
||||
function stubWorker(): {
|
||||
worker: Worker
|
||||
sent: { t: string; id: number }[]
|
||||
deliver: (frame: unknown) => void
|
||||
fail: (message: string) => void
|
||||
} {
|
||||
const listeners: StubListener[] = []
|
||||
const errorListeners: StubListener[] = []
|
||||
const sent: { t: string; id: number }[] = []
|
||||
const worker = {
|
||||
addEventListener: (type: string, listener: Listener) => { if (type === 'message') listeners.push(listener) },
|
||||
addEventListener: (type: string, listener: StubListener) => {
|
||||
if (type === 'message') listeners.push(listener)
|
||||
if (type === 'error') errorListeners.push(listener)
|
||||
},
|
||||
postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) },
|
||||
} as unknown as Worker
|
||||
return { worker, sent, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) } }
|
||||
return {
|
||||
worker,
|
||||
sent,
|
||||
deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) },
|
||||
fail: (message) => { for (const listener of errorListeners) listener({ message }) },
|
||||
}
|
||||
}
|
||||
|
||||
// A normal reply resolves and says nothing on the console.
|
||||
@@ -129,3 +143,90 @@ function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deli
|
||||
// A late reply to an aborted request must not resurrect it.
|
||||
deliver({ t: 'res', id: 1, status: 200, headers: {}, message: 'late' })
|
||||
}
|
||||
|
||||
// A logical Gateway stream carries decoded values and one terminal frame.
|
||||
{
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const signal = new AbortController()
|
||||
const stream = tunnel.open('session/follow', { args: { sessionId: 'session-1' } }, signal.signal)
|
||||
[Symbol.asyncIterator]()
|
||||
const first = stream.next()
|
||||
check('a logical stream opens on the worker-local carrier', sent[0], {
|
||||
t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
|
||||
})
|
||||
deliver({ t: 'stream-item', id: 1, value: { type: 'baseline' } })
|
||||
check('a logical stream yields decoded values', await first, { value: { type: 'baseline' }, done: false })
|
||||
const ended = stream.next()
|
||||
deliver({ t: 'stream-end', id: 1 })
|
||||
check('a logical stream closes normally', await ended, { done: true, value: undefined })
|
||||
check('normal stream completion sends no cancellation', sent, [
|
||||
{ t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } } },
|
||||
])
|
||||
}
|
||||
|
||||
// Host failures retain their code and details for the Gateway Client bundle to normalize.
|
||||
{
|
||||
const { worker, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const pending = tunnel.open('session/follow', {}, new AbortController().signal).next()
|
||||
deliver({
|
||||
t: 'stream-error',
|
||||
id: 1,
|
||||
failure: {
|
||||
kind: 'remote',
|
||||
code: 'session-not-found',
|
||||
message: 'fixture Session is absent',
|
||||
details: { sessionId: 'session-1' },
|
||||
},
|
||||
})
|
||||
const failure = await pending.then(() => undefined, (error: unknown) => error as {
|
||||
message: string
|
||||
dshRemoteStreamFailure: unknown
|
||||
})
|
||||
check('a logical Host failure retains its structural marker', {
|
||||
message: failure?.message,
|
||||
dshRemoteStreamFailure: failure?.dshRemoteStreamFailure,
|
||||
}, {
|
||||
message: 'fixture Session is absent',
|
||||
dshRemoteStreamFailure: {
|
||||
kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Caller cancellation keeps the caller's exact reason and reaches the worker once.
|
||||
{
|
||||
const { worker, sent, deliver } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const abort = new AbortController()
|
||||
const pending = tunnel.open('workspace/follow', {}, abort.signal).next()
|
||||
const reason = new Error('caller stopped the Workspace feed')
|
||||
abort.abort(reason)
|
||||
deliver({ t: 'stream-item', id: 1, value: 'late' })
|
||||
check('logical stream cancellation preserves the caller reason', await pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error === reason ? 'same reason' : 'different reason',
|
||||
), 'same reason')
|
||||
check('logical stream cancellation reaches the worker', sent.at(-1), { t: 'abort', id: 1 })
|
||||
}
|
||||
|
||||
// A failed worker is a carrier failure, not a fabricated Host Remote error.
|
||||
{
|
||||
warnings.length = 0
|
||||
const { worker, fail } = stubWorker()
|
||||
const tunnel = new WorkerTunnel(worker)
|
||||
const pending = tunnel.open('$events', { args: {} }, new AbortController().signal).next()
|
||||
fail('worker crashed')
|
||||
const failure = await pending.then(() => undefined, (error: unknown) => error as {
|
||||
message: string
|
||||
dshRemoteStreamFailure: unknown
|
||||
})
|
||||
check('worker failure carries the carrier marker', {
|
||||
message: failure?.message,
|
||||
dshRemoteStreamFailure: failure?.dshRemoteStreamFailure,
|
||||
}, {
|
||||
message: 'web-preview tunnel: worker failed: worker crashed',
|
||||
dshRemoteStreamFailure: { kind: 'carrier' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TunnelOutboundFrame } from '../../src/transport/frames.ts'
|
||||
import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts'
|
||||
|
||||
function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } {
|
||||
const frames: TunnelOutboundFrame[] = []
|
||||
const server = new TunnelServer({
|
||||
port: { postMessage: (frame) => { frames.push(frame) } },
|
||||
requestListener: () => Promise.reject(new Error('fixture has no HTTP listener')),
|
||||
})
|
||||
return { server, frames }
|
||||
}
|
||||
|
||||
function seams(openStream: TunnelSeams['openStream']): TunnelSeams {
|
||||
return {
|
||||
directFetch: () => Promise.reject(new Error('fixture has no direct fetch')),
|
||||
bootPayload: () => ({}),
|
||||
openStream,
|
||||
streamFailure: error => ({
|
||||
code: 'fixture-stream-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { fixture: true },
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('worker tunnel logical streams', () => {
|
||||
it('drains a pre-boot open through the worker-local Gateway seam', async () => {
|
||||
const { server, frames } = harness()
|
||||
const seen: unknown[] = []
|
||||
server.handleMessage({
|
||||
t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
|
||||
})
|
||||
expect(frames).toEqual([])
|
||||
|
||||
server.serve(seams(async (endpoint, payload, signal) => {
|
||||
seen.push(endpoint, payload, signal)
|
||||
return (async function *(): AsyncGenerator {
|
||||
yield { type: 'baseline' }
|
||||
yield { type: 'event', seq: 1 }
|
||||
})()
|
||||
}))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([
|
||||
{ t: 'stream-item', id: 1, value: { type: 'baseline' } },
|
||||
{ t: 'stream-item', id: 1, value: { type: 'event', seq: 1 } },
|
||||
{ t: 'stream-end', id: 1 },
|
||||
])
|
||||
})
|
||||
expect(seen).toEqual([
|
||||
'session/follow',
|
||||
{ args: { sessionId: 'session-1' } },
|
||||
expect.any(AbortSignal),
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels one logical stream without emitting a terminal frame', async () => {
|
||||
const { server, frames } = harness()
|
||||
const opened = Promise.withResolvers<AbortSignal>()
|
||||
const stopped = Promise.withResolvers<undefined>()
|
||||
server.serve(seams(async (_endpoint, _payload, signal) => {
|
||||
opened.resolve(signal)
|
||||
return (async function *(): AsyncGenerator {
|
||||
yield 'ready'
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
stopped.resolve(undefined)
|
||||
})()
|
||||
}))
|
||||
server.handleMessage({ t: 'stream-open', id: 2, endpoint: '$events', payload: { args: {} } })
|
||||
const signal = await opened.promise
|
||||
await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 2, value: 'ready' }) })
|
||||
|
||||
server.handleMessage({ t: 'abort', id: 2 })
|
||||
await stopped.promise
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(frames).toEqual([{ t: 'stream-item', id: 2, value: 'ready' }])
|
||||
})
|
||||
|
||||
it('maps a Host stream failure through Gateway-owned fields', async () => {
|
||||
const { server, frames } = harness()
|
||||
server.serve(seams(async () => { throw new Error('Host stream exploded') }))
|
||||
server.handleMessage({ t: 'stream-open', id: 3, endpoint: 'probe/watch', payload: {} })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(frames).toEqual([{
|
||||
t: 'stream-error',
|
||||
id: 3,
|
||||
failure: {
|
||||
kind: 'remote',
|
||||
code: 'fixture-stream-failed',
|
||||
message: 'Host stream exploded',
|
||||
details: { fixture: true },
|
||||
},
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses queued and future streams after boot failure as carrier failures', () => {
|
||||
const { server, frames } = harness()
|
||||
server.handleMessage({ t: 'stream-open', id: 4, endpoint: '$events', payload: {} })
|
||||
server.fail(new Error('image failed'))
|
||||
server.handleMessage({ t: 'stream-open', id: 5, endpoint: '$events', payload: {} })
|
||||
|
||||
expect(frames).toEqual([4, 5].map(id => ({
|
||||
t: 'stream-error',
|
||||
id,
|
||||
failure: { kind: 'carrier', message: 'Error: image failed' },
|
||||
})))
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,6 @@ import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
@@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
approval: ApprovalService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Ask composed answerers for one decision. Return an outcome to claim the
|
||||
* request or call `next()`; failure yields the fail-closed default.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* An approval question was put to the answerer chain — log-only audit
|
||||
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
|
||||
* it with the `approval/decided` that always follows; `toolName` is the
|
||||
* tool the question is about, `callId` the exact tool call when the asker
|
||||
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
|
||||
* permission-decision reason).
|
||||
*/
|
||||
'approval/asked': {
|
||||
id: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
}
|
||||
/**
|
||||
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
|
||||
* Exactly one per ask, appended when the outcome is known: a decision, a
|
||||
* cancellation, or the fail-closed `'unavailable'`.
|
||||
*/
|
||||
'approval/decided': {
|
||||
id: ApprovalRequestId
|
||||
outcome: ApprovalOutcome
|
||||
}
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
@@ -73,7 +38,7 @@ declare module '@deepseek-ai/dsh-session/types' {
|
||||
}
|
||||
|
||||
import { ApprovalRequestId } from './types.ts'
|
||||
import type { ApprovalOutcome } from './types.ts'
|
||||
import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'
|
||||
|
||||
export { ApprovalRequestId } from './types.ts'
|
||||
export type { ApprovalOutcome } from './types.ts'
|
||||
@@ -150,7 +115,7 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
|
||||
* Readonly same-process permission question. `callId` links to an already
|
||||
* presented tool call, so arguments are not duplicated here.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
export interface ApprovalRequest extends ApprovalRequestEvent {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
@@ -27,3 +30,61 @@ export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* An approval question was put to the answerer chain — log-only audit
|
||||
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
|
||||
* it with the `approval/decided` that always follows; `toolName` is the
|
||||
* tool the question is about, `callId` the exact tool call when the asker
|
||||
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
|
||||
* permission-decision reason).
|
||||
*/
|
||||
'approval/asked': {
|
||||
id: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
}
|
||||
/**
|
||||
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
|
||||
* Exactly one per ask, appended when the outcome is known: a decision, a
|
||||
* cancellation, or the fail-closed `'unavailable'`.
|
||||
*/
|
||||
'approval/decided': {
|
||||
id: ApprovalRequestId
|
||||
outcome: ApprovalOutcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Client-safe payload declared for the approval answerer waterfall. */
|
||||
export interface ApprovalRequestEvent {
|
||||
/** Agent identity projected to the corresponding Client Context in transit. */
|
||||
readonly agent: Agent
|
||||
/** Tool whose operation requires a decision. */
|
||||
readonly toolName: string
|
||||
/** Exact tool call being decided, when available. */
|
||||
readonly callId?: CallId
|
||||
/** Human-readable reason supplied by the asker. */
|
||||
readonly reason?: string
|
||||
/** Cancellation lifetime of the pending request. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Ask composed answerers for one decision. Return an outcome to claim the
|
||||
* request or call `next()` to delegate.
|
||||
* @param req - pending approval request.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(
|
||||
this: Scoped<object>,
|
||||
req: ApprovalRequestEvent,
|
||||
next: () => Promise<ApprovalOutcome>,
|
||||
): Promise<ApprovalOutcome>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
|
||||
import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequestEvent,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
|
||||
@@ -25,7 +27,7 @@ export type {
|
||||
} from './types.ts'
|
||||
|
||||
/** Request for a human answer. */
|
||||
export interface AskUserQuestionRequest {
|
||||
export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Exact live calling agent, when the request came from an agent tool call. */
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/**
|
||||
* Wire-safe question and answer types, free of cordis/service imports so browser
|
||||
* type chains (apiproxy api → client) can consume them without loading this
|
||||
* package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-user-questions/types
|
||||
*/
|
||||
/** Client-safe question, answer, and event types. @module @deepseek-ai/dsh-user-questions/types */
|
||||
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent/types'
|
||||
|
||||
/** One selectable answer offered to the user. */
|
||||
export interface AskUserQuestionOption {
|
||||
@@ -64,3 +62,29 @@ export interface AskUserQuestionAnswer {
|
||||
/** Structured answers keyed by question id. */
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
}
|
||||
|
||||
/** Client-safe payload declared for the user-question answerer waterfall. */
|
||||
export interface AskUserQuestionRequestEvent {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Agent identity projected to the corresponding Client Context in transit. */
|
||||
agent?: Agent
|
||||
/** Cancellation lifetime of the pending request. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Ask composed answerers for structured user input. Return an answer to
|
||||
* claim the request or call `next()` to delegate.
|
||||
* @param request - pending user-question request.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'user-questions/request'(
|
||||
this: Scoped<object>,
|
||||
request: AskUserQuestionRequestEvent,
|
||||
next: () => Promise<AskUserQuestionAnswer>,
|
||||
): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -976,7 +976,7 @@ class FaceAnalyzer {
|
||||
binding: GatewayBinding,
|
||||
method: ts.MethodDeclaration,
|
||||
invocation:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string },
|
||||
): InvocationModel {
|
||||
if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) {
|
||||
@@ -1106,13 +1106,15 @@ class FaceAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
const resultType = this.remoteResultType(method)
|
||||
const mode = invocation.kind === 'direct' ? invocation.mode : undefined
|
||||
const resultType = this.remoteResultType(method, mode)
|
||||
return {
|
||||
id: `${registration.name}#${binding.namespace}/${exportedMethod}`,
|
||||
service: binding.service,
|
||||
namespace: binding.namespace,
|
||||
method: exportedMethod,
|
||||
...(exportedMethod === methodName ? {} : { implementation: methodName }),
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
invocation: receiver,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
parameters,
|
||||
@@ -1215,11 +1217,11 @@ class FaceAnalyzer {
|
||||
private remoteMarker(
|
||||
member: ts.ClassElement,
|
||||
):
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined {
|
||||
let found:
|
||||
| { readonly kind: 'direct'; readonly exportName?: string }
|
||||
| { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' }
|
||||
| { readonly kind: 'context'; readonly context: string; readonly exportName?: string }
|
||||
| undefined
|
||||
for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) {
|
||||
@@ -1229,12 +1231,28 @@ class FaceAnalyzer {
|
||||
marker = { kind: 'direct' }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'Remote')) {
|
||||
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name')
|
||||
const exportName = stringLiteralValue(expression.arguments[0])
|
||||
if (exportName === undefined || !isRemoteSegment(exportName)) {
|
||||
this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters')
|
||||
if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one name or options object')
|
||||
const argument = expression.arguments[0]
|
||||
if (argument === undefined) this.fail(expression, 'Remote() requires one name or options object')
|
||||
const exportName = stringLiteralValue(argument)
|
||||
if (exportName !== undefined) {
|
||||
if (!isRemoteSegment(exportName)) {
|
||||
this.fail(argument, 'Remote() name must contain only RPC endpoint segment characters')
|
||||
}
|
||||
marker = { kind: 'direct', exportName }
|
||||
} else {
|
||||
if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) {
|
||||
this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
|
||||
}
|
||||
const [property] = argument.properties
|
||||
if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"')
|
||||
if (!ts.isPropertyAssignment(property)
|
||||
|| memberName(property.name) !== 'mode'
|
||||
|| stringLiteralValue(property.initializer) !== 'stream') {
|
||||
this.fail(property, 'Remote() options must contain exactly mode: "stream"')
|
||||
}
|
||||
marker = { kind: 'direct', mode: 'stream' }
|
||||
}
|
||||
marker = { kind: 'direct', exportName }
|
||||
} else if (ts.isCallExpression(expression)
|
||||
&& this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) {
|
||||
if (expression.arguments.length < 1 || expression.arguments.length > 2) {
|
||||
@@ -1259,16 +1277,27 @@ class FaceAnalyzer {
|
||||
return found
|
||||
}
|
||||
|
||||
private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode {
|
||||
private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode {
|
||||
const authored = this.requiredType(method, method.type, 'return')
|
||||
if (!ts.isTypeReferenceNode(authored)) return authored
|
||||
const symbol = this.checker.getSymbolAtLocation(authored.typeName)
|
||||
const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
|
||||
const resultType = authored.typeArguments?.[0]
|
||||
if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored
|
||||
const declaration = preferredDeclaration(resolved)
|
||||
if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored
|
||||
return resultType
|
||||
if (ts.isTypeReferenceNode(authored)) {
|
||||
const symbol = this.checker.getSymbolAtLocation(authored.typeName)
|
||||
const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol)
|
||||
const resultType = authored.typeArguments?.[0]
|
||||
const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise']
|
||||
const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved)
|
||||
if (resolved !== undefined
|
||||
&& wrappers.includes(resolved.name)
|
||||
&& resultType !== undefined
|
||||
&& authored.typeArguments?.length === 1
|
||||
&& declaration !== undefined
|
||||
&& isStandardLibraryFile(declaration.getSourceFile().fileName)) {
|
||||
return resultType
|
||||
}
|
||||
}
|
||||
if (mode === 'stream') {
|
||||
this.fail(method, 'stream Remote methods must return Iterable<T> or AsyncIterable<T>')
|
||||
}
|
||||
return authored
|
||||
}
|
||||
|
||||
private isGlobalAbortSignal(type: ts.TypeNode): boolean {
|
||||
|
||||
@@ -282,6 +282,7 @@ export class FaceModelEmitter {
|
||||
if (invocation.implementation !== undefined) {
|
||||
lines.push(` implementation: ${quote(invocation.implementation)},`)
|
||||
}
|
||||
if (invocation.mode !== undefined) lines.push(` mode: ${quote(invocation.mode)},`)
|
||||
if (invocation.invocation.kind === 'direct') {
|
||||
lines.push(' invocation: { kind: \'direct\' },')
|
||||
} else {
|
||||
@@ -467,9 +468,11 @@ export class FaceModelEmitter {
|
||||
`${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
|
||||
const result = this.renderer.renderType(invocation.result.type, referenceNames)
|
||||
// The Client Remote face delivers the carrier's outcome, so every generated
|
||||
// consumer signature resolves to a result the caller reads instead of a
|
||||
// value it must guard with its own try/catch.
|
||||
if (invocation.mode === 'stream') {
|
||||
return `(${parameters.join(', ')}) => AsyncIterable<${result}>`
|
||||
}
|
||||
// The unary Client Remote face delivers the carrier's outcome, so every
|
||||
// generated consumer signature resolves to a result the caller reads.
|
||||
return `(${parameters.join(', ')}) => Promise<RemoteResult<${result}>>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface InvocationModel {
|
||||
readonly namespace: string
|
||||
readonly method: string
|
||||
readonly implementation?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
| {
|
||||
|
||||
+6
@@ -23,6 +23,12 @@ export class GoalService extends TypertRemoteService {
|
||||
rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *watch(agent: Agent, signal: AbortSignal): AsyncIterable<CreateGoalResult> {
|
||||
signal.throwIfAborted()
|
||||
yield { ref: agent.id }
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -60,7 +60,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
|
||||
export function Remote(exportName: string):
|
||||
export function Remote(option: string | { readonly mode: 'stream' }):
|
||||
<This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
|
||||
@@ -20,6 +20,7 @@ interface RuntimeSchema {
|
||||
|
||||
interface RuntimeDescriptor {
|
||||
readonly id: string
|
||||
readonly mode?: 'stream'
|
||||
readonly cancellation?: { readonly parameter: 'signal' }
|
||||
readonly parameters: readonly {
|
||||
readonly wire: string
|
||||
@@ -66,7 +67,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
|
||||
const model = remotePackage(fixtureRoot)
|
||||
expect(model.services).toEqual([])
|
||||
expect(model.invocations).toHaveLength(2)
|
||||
expect(model.invocations).toHaveLength(3)
|
||||
expect(model.invocations[0]).toMatchObject({
|
||||
id: '@fixture/remote#goals/create',
|
||||
service: 'goals',
|
||||
@@ -111,6 +112,22 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
}],
|
||||
result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' },
|
||||
})
|
||||
expect(model.invocations[2]).toMatchObject({
|
||||
id: '@fixture/remote#goals/watch',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'watch',
|
||||
mode: 'stream',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'agent',
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
|
||||
})
|
||||
|
||||
expect(artifact?.js).toContain('invocations: [')
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
@@ -124,6 +141,9 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RemoteResult<RenameGoalResult>>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable<CreateGoalResult>",
|
||||
)
|
||||
|
||||
const remoteJs = artifact?.remote?.js
|
||||
if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript')
|
||||
@@ -136,6 +156,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
|
||||
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
|
||||
expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false)
|
||||
expect(generated.TYPERT_REMOTE.descriptors[2]?.mode).toBe('stream')
|
||||
|
||||
const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap
|
||||
expect(declarationMap).toMatchObject({
|
||||
@@ -241,17 +262,15 @@ export type GenericResult = {
|
||||
' RenameGoalResult,\n GenericRequest,\n GenericResult,\n',
|
||||
)
|
||||
.replace(
|
||||
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
|
||||
` rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote
|
||||
" @Remote({ mode: 'stream' })\n async *watch",
|
||||
` @Remote
|
||||
dispatch(request: GenericRequest): GenericResult {
|
||||
if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } }
|
||||
return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } }
|
||||
}
|
||||
}`,
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *watch`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
@@ -292,16 +311,14 @@ export interface BoxPayload {
|
||||
' RenameGoalResult,\n Box,\n BoxPayload,\n',
|
||||
)
|
||||
.replace(
|
||||
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
|
||||
` rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote
|
||||
" @Remote({ mode: 'stream' })\n async *watch",
|
||||
` @Remote
|
||||
box(request: Box<BoxPayload>): Box<BoxPayload> {
|
||||
return request
|
||||
}
|
||||
}`,
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *watch`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
@@ -313,16 +330,14 @@ export interface BoxPayload {
|
||||
it('quotes aliased methods in generated namespace interfaces', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace(
|
||||
' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}',
|
||||
` rename(request: RenameGoalRequest): RenameGoalResult {
|
||||
return { renamed: request.title.length > 0 }
|
||||
}
|
||||
|
||||
@Remote('create-goal')
|
||||
" @Remote({ mode: 'stream' })\n async *watch",
|
||||
` @Remote('create-goal')
|
||||
createAlias(request: CreateGoalRequest): CreateGoalResult {
|
||||
return { ref: request.title }
|
||||
}
|
||||
}`,
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
async *watch`,
|
||||
))
|
||||
|
||||
const [artifact] = new WorkspaceTypertGenerator(root).generate()
|
||||
@@ -343,8 +358,9 @@ export interface BoxPayload {
|
||||
it('rejects a Remote export after its last Remote method is removed', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source
|
||||
.replace(' @Remote\n', '')
|
||||
.replace(" @RemoteScope('agent')\n", ''))
|
||||
.replaceAll(' @Remote\n', '')
|
||||
.replace(" @RemoteScope('agent')\n", '')
|
||||
.replace(" @Remote({ mode: 'stream' })\n", ''))
|
||||
editFile(root, 'packages/remote/src/types.ts', source => `${source}
|
||||
|
||||
/** @typert schema */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { TypertContextMap } from './types.ts'
|
||||
import type { RemoteFailure, TypertContextMap } from './types.ts'
|
||||
|
||||
const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
@@ -37,22 +37,42 @@ export class TypertLookupFailure<Failure = unknown> extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A business Remote rejection preserved by unary and stream carriers. */
|
||||
export class TypertRemoteFailure extends Error {
|
||||
/** Stable caller-facing failure payload. */
|
||||
readonly failure: RemoteFailure
|
||||
|
||||
/**
|
||||
* Wrap one business rejection for transport without changing its code or details.
|
||||
* @param failure - business failure returned unchanged to the caller.
|
||||
*/
|
||||
constructor(failure: RemoteFailure) {
|
||||
super(failure.message)
|
||||
this.name = 'TypertRemoteFailure'
|
||||
this.failure = failure
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
InvocationDescriptor,
|
||||
InvocationParameterDescriptor,
|
||||
InvocationSourceLocation,
|
||||
RemoteFailure,
|
||||
RemoteResult,
|
||||
TypertClientEventListener,
|
||||
TypertClientRemote,
|
||||
TypertClientContextBinder,
|
||||
TypertClientContextAdapter,
|
||||
TypertCodec,
|
||||
TypertContext,
|
||||
TypertContextAdapter,
|
||||
TypertContextMap,
|
||||
TypertContextRegistry,
|
||||
TypertContextWire,
|
||||
TypertDisposer,
|
||||
TypertForwardableEvent,
|
||||
TypertHostContextProvider,
|
||||
TypertForwardableEventEntry,
|
||||
TypertHostContextAdapter,
|
||||
TypertHostContextIdentity,
|
||||
TypertHostContextResolver,
|
||||
TypertLocalRegistry,
|
||||
TypertLookup,
|
||||
@@ -103,9 +123,17 @@ export interface RemoteMethodMarker {
|
||||
readonly method: string
|
||||
/** Endpoint method when it differs from the implementation member. */
|
||||
readonly exportName?: string
|
||||
/** Stream methods yield many independently validated result items. */
|
||||
readonly mode?: 'stream'
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
/** Options for a non-unary Remote method. */
|
||||
export interface RemoteMethodOptions {
|
||||
/** Deliver each Iterable item over the shared logical-stream carrier. */
|
||||
readonly mode: 'stream'
|
||||
}
|
||||
|
||||
type RemoteMethodDecorator = <This extends object, Args extends unknown[], Result>(
|
||||
method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
@@ -120,6 +148,7 @@ interface RemoteInitializerContext<This extends object> {
|
||||
|
||||
interface StoredRemoteMethodMarker {
|
||||
readonly exportName?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly invocation: RemoteInvocationMarker
|
||||
}
|
||||
|
||||
@@ -170,31 +199,47 @@ export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void
|
||||
/**
|
||||
* Mark one public instance method under a distinct exported method name.
|
||||
* @param exportName - Remote endpoint method, without a namespace or slash.
|
||||
* Mark one public instance method under an exported name or as a logical stream.
|
||||
* @param option - endpoint method name or stream delivery mode.
|
||||
* @returns a standard method decorator.
|
||||
*/
|
||||
export function Remote(exportName: string): RemoteMethodDecorator
|
||||
export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
methodOrExportName: string | ((this: This, ...args: Args) => Result),
|
||||
methodExportOrOptions: string | RemoteMethodOptions | ((this: This, ...args: Args) => Result),
|
||||
context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void | RemoteMethodDecorator {
|
||||
if (typeof methodOrExportName === 'string') {
|
||||
validateName('Remote export name', methodOrExportName)
|
||||
return function <DecoratorThis extends object, DecoratorArgs extends unknown[], DecoratorResult>(
|
||||
_method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult,
|
||||
decoratorContext: ClassMethodDecoratorContext<
|
||||
DecoratorThis,
|
||||
(this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult
|
||||
>,
|
||||
): void {
|
||||
addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName)
|
||||
if (typeof methodExportOrOptions === 'string') {
|
||||
validateName('Remote export name', methodExportOrOptions)
|
||||
return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions)
|
||||
}
|
||||
if (typeof methodExportOrOptions === 'object') {
|
||||
if (remoteOptionMode(methodExportOrOptions) !== 'stream'
|
||||
|| Reflect.ownKeys(methodExportOrOptions).length !== 1) {
|
||||
throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"')
|
||||
}
|
||||
return remoteDecorator({ kind: 'direct' }, 'stream')
|
||||
}
|
||||
if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing')
|
||||
addMarkerInitializer(context, { kind: 'direct' })
|
||||
}
|
||||
|
||||
function remoteOptionMode(options: object): unknown {
|
||||
return Reflect.get(options, 'mode') as unknown
|
||||
}
|
||||
|
||||
function remoteDecorator(
|
||||
invocation: RemoteInvocationMarker,
|
||||
mode?: 'stream',
|
||||
exportName?: string,
|
||||
): RemoteMethodDecorator {
|
||||
return function <This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void {
|
||||
addMarkerInitializer(context, invocation, mode, exportName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a decorator for a method resolved from one Remote Scope.
|
||||
* @param key - scope key declared through the Context map.
|
||||
@@ -207,12 +252,7 @@ export function RemoteScope(
|
||||
): RemoteMethodDecorator {
|
||||
validateName('Scope key', key)
|
||||
if (exportName !== undefined) validateName('Remote export name', exportName)
|
||||
return function <This extends object, Args extends unknown[], Result>(
|
||||
_method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>,
|
||||
): void {
|
||||
addMarkerInitializer(context, { kind: 'context', context: key }, exportName)
|
||||
}
|
||||
return remoteDecorator({ kind: 'context', context: key }, undefined, exportName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,6 +270,7 @@ export function remoteMethods(service: object): readonly RemoteMethodMarker[] {
|
||||
function addMarkerInitializer<This extends object>(
|
||||
context: RemoteInitializerContext<This>,
|
||||
invocation: RemoteInvocationMarker,
|
||||
mode?: 'stream',
|
||||
exportName?: string,
|
||||
): void {
|
||||
if (context.private || context.static || typeof context.name !== 'string') {
|
||||
@@ -241,7 +282,7 @@ function addMarkerInitializer<This extends object>(
|
||||
if (prototype === null) {
|
||||
throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`)
|
||||
}
|
||||
mark(prototype, method, invocation, exportName)
|
||||
mark(prototype, method, invocation, mode, exportName)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +290,7 @@ function mark(
|
||||
prototype: object,
|
||||
method: string,
|
||||
invocation: RemoteInvocationMarker,
|
||||
mode?: 'stream',
|
||||
exportName?: string,
|
||||
): void {
|
||||
let table = markers.get(prototype)
|
||||
@@ -258,11 +300,14 @@ function mark(
|
||||
}
|
||||
const marker: StoredRemoteMethodMarker = {
|
||||
...(exportName === undefined || exportName === method ? {} : { exportName }),
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
invocation: Object.freeze(invocation),
|
||||
}
|
||||
const current = table.get(method)
|
||||
if (current !== undefined) {
|
||||
if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return
|
||||
if (current.exportName === marker.exportName
|
||||
&& current.mode === marker.mode
|
||||
&& sameInvocation(current.invocation, invocation)) return
|
||||
throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`)
|
||||
}
|
||||
table.set(method, Object.freeze(marker))
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface RemoteFailure {
|
||||
* What every generated Remote method resolves to. The Remote face itself folds
|
||||
* carrier failures into the error branch, so no consumer wraps a call to
|
||||
* recover one; only assembly faults (arity, an unmounted method, a missing
|
||||
* Context binder) still reject.
|
||||
* Context adapter) still reject.
|
||||
* @template T - the Host method's business result.
|
||||
*/
|
||||
export type RemoteResult<T> =
|
||||
@@ -64,23 +64,92 @@ export type RemoteResult<T> =
|
||||
/** Merge-extensible scoped Remote method signatures generated for consumers. */
|
||||
export interface TypertRemoteScopeMap {}
|
||||
|
||||
type TypertEventParameters<Event extends keyof Events> =
|
||||
Events[Event] extends (...args: infer Args) => unknown ? Args : never
|
||||
|
||||
type TypertEventResult<Event extends keyof Events> =
|
||||
Events[Event] extends (...args: never[]) => infer Result ? Result : never
|
||||
|
||||
type TypertProjectedContextKey = Extract<keyof TypertLookupMap, keyof TypertContextMap>
|
||||
|
||||
type TypertProjectedContextSubject = {
|
||||
[Key in TypertProjectedContextKey]: TypertLookupHost<TypertLookupMap[Key]>
|
||||
}[TypertProjectedContextKey]
|
||||
|
||||
type TypertAgentScopedRequest<Request> = Request extends object
|
||||
? 'agent' extends keyof Request
|
||||
? Exclude<Request['agent'], undefined> extends TypertProjectedContextSubject ? Request : never
|
||||
: never
|
||||
: never
|
||||
|
||||
type TypertWaterfallEvent<Event extends keyof Events> =
|
||||
unknown extends ThisParameterType<Events[Event]>
|
||||
? never
|
||||
: TypertEventParameters<Event> extends [infer Request, infer Next]
|
||||
? Next extends () => TypertEventResult<Event>
|
||||
? TypertEventResult<Event> extends Promise<unknown>
|
||||
? TypertAgentScopedRequest<Request> extends never ? never : Event
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
|
||||
type TypertForwardingMode<Event extends keyof Events> =
|
||||
unknown extends ThisParameterType<Events[Event]>
|
||||
? TypertEventResult<Event> extends void ? 'emit' : never
|
||||
: TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Cordis event names the Remote Event carrier can preserve without a second
|
||||
* signature declaration: unscoped `void` notifications and scoped async
|
||||
* waterfalls whose final parameter is their same-result `next()` callback.
|
||||
*/
|
||||
export type TypertForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
|
||||
}[keyof Events]
|
||||
|
||||
/** Event and dispatch mode accepted by the Remote Event source. */
|
||||
export type TypertForwardableEventEntry = {
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
|
||||
? Mode extends 'emit' | 'waterfall'
|
||||
? { readonly event: Event; readonly mode: Mode }
|
||||
: 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>
|
||||
/** Legal `$on` keys selected from the carrier-compatible Cordis event declarations. */
|
||||
export type TypertRemoteEvent = Extract<TypertForwardableEvent, keyof TypertRemoteEventSelection>
|
||||
|
||||
type TypertClientAgent<Value> =
|
||||
Exclude<Value, undefined> extends TypertProjectedContextSubject
|
||||
? Context | Extract<Value, undefined>
|
||||
: Value
|
||||
|
||||
type TypertClientEventRequest<Request> = Request extends object
|
||||
? { [Key in keyof Request]: Key extends 'agent' ? TypertClientAgent<Request[Key]> : Request[Key] }
|
||||
: never
|
||||
|
||||
type TypertScopedClientEventListener<Event extends TypertRemoteEvent> =
|
||||
Events[Event] extends (request: infer Request, next: infer Next) => infer Result
|
||||
? (
|
||||
this: Context,
|
||||
request: TypertClientEventRequest<Request>,
|
||||
next: Next,
|
||||
) => Result
|
||||
: never
|
||||
|
||||
/**
|
||||
* Listener derived from one selected Cordis event declaration. Scoped Host
|
||||
* subjects become the resolved Client `Context`; one-way notifications retain
|
||||
* their declaration unchanged.
|
||||
* @template Event - selected Remote Event name.
|
||||
*/
|
||||
export type TypertClientEventListener<Event extends TypertRemoteEvent> =
|
||||
unknown extends ThisParameterType<Events[Event]>
|
||||
? Events[Event]
|
||||
: TypertScopedClientEventListener<Event>
|
||||
|
||||
/**
|
||||
* Resolve one direct Remote namespace from the generated flat endpoint map.
|
||||
@@ -181,6 +250,8 @@ export interface InvocationDescriptor {
|
||||
readonly method: string
|
||||
/** Service member invoked when the exported method name is an alias. */
|
||||
readonly implementation?: string
|
||||
/** Absent for unary calls; stream calls validate and deliver every yielded item. */
|
||||
readonly mode?: 'stream'
|
||||
/** Receiver selection mode. */
|
||||
readonly invocation:
|
||||
| { readonly kind: 'direct' }
|
||||
@@ -192,7 +263,7 @@ export interface InvocationDescriptor {
|
||||
}
|
||||
/** Optional consuming-Context projection for one direct lookup parameter. */
|
||||
readonly scope?: {
|
||||
/** Context kind whose Client binder supplies the identity. */
|
||||
/** Context kind whose Client adapter supplies the identity. */
|
||||
readonly context: string
|
||||
/** Lookup parameter wire field replaced by the Context identity. */
|
||||
readonly wire: string
|
||||
@@ -204,7 +275,7 @@ export interface InvocationDescriptor {
|
||||
/** Reserved final Host method parameter. */
|
||||
readonly parameter: 'signal'
|
||||
}
|
||||
/** Codec for the resolved method result. */
|
||||
/** Codec for the unary result or each yielded stream item. */
|
||||
readonly result: TypertCodec
|
||||
/** Source declaration used only for diagnostics. */
|
||||
readonly sourceLocation?: InvocationSourceLocation
|
||||
@@ -227,26 +298,15 @@ export interface TypertClientRemote extends TypertRemoteNamespaceMap {
|
||||
*/
|
||||
$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.
|
||||
* Subscribe to one forwarded Host event. Notifications run in registration
|
||||
* order and isolate failures; scoped waterfalls return, delegate through
|
||||
* `next()`, or reject the Host dispatch.
|
||||
* @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`.
|
||||
* @param listener - receives the Client projection of the Cordis `Events` declaration.
|
||||
* @returns disposer owned by the calling fiber.
|
||||
*/
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
/**
|
||||
* Hand one decoded forwarded frame to the subscription table. The carrier
|
||||
* owning the Host frame sink calls this; a consumer subscribes with
|
||||
* {@link TypertClientRemote.$on} and never calls it.
|
||||
*
|
||||
* `event` is a plain string because this is the wire boundary: the name is
|
||||
* whatever the Host assembly's allowlist selected, and one nobody subscribed
|
||||
* to is dropped silently.
|
||||
* @param event - forwarded Host event name, exactly as the Host emitted it.
|
||||
* @param args - the Host argument list, already JSON-decoded.
|
||||
*/
|
||||
$dispatch(event: string, args: readonly unknown[]): void
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,33 +350,58 @@ export interface TypertLookupDefinition {
|
||||
readonly wireTypeSymbol: string
|
||||
}
|
||||
|
||||
/** Host resolver for one scoped Remote kind. */
|
||||
export interface TypertHostContextProvider<Wire = unknown> {
|
||||
/** Wire field carrying the Context identity. */
|
||||
readonly wire: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
/** Bidirectional projection between one environment's Context and its wire identity. */
|
||||
export interface TypertContextAdapter<Wire = unknown> {
|
||||
/**
|
||||
* Resolve a wire identity to its live scoped Context.
|
||||
* Read the identity represented by a live Context.
|
||||
* @param ctx - Context in this adapter's environment.
|
||||
* @returns the wire identity, or `undefined` when the Context has another kind.
|
||||
*/
|
||||
identity(ctx: Context): Wire | undefined
|
||||
/**
|
||||
* Resolve a wire identity to a live Context in this adapter's environment.
|
||||
* An asynchronous Client resolver may wait for its owner to create the Context.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the scoped Context, or `undefined` when unavailable.
|
||||
* @returns the Context, or `undefined` when it is unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
|
||||
}
|
||||
|
||||
/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */
|
||||
/** Host Context adapter plus the wire declaration used by strict Remote methods. */
|
||||
export interface TypertHostContextAdapter<Wire = unknown> extends TypertContextAdapter<Wire> {
|
||||
/** Wire field carrying the Context identity. */
|
||||
readonly wire: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
}
|
||||
|
||||
/** Composition-owned resolver replacing one Host Context adapter's default lookup policy. */
|
||||
export type TypertHostContextResolver<Wire = unknown> = (
|
||||
id: Wire,
|
||||
) => Context | undefined | Promise<Context | undefined>
|
||||
|
||||
/** Client resolver for the identity carried by the calling scoped Context. */
|
||||
export interface TypertClientContextBinder<Wire = unknown> {
|
||||
/** Client-side bidirectional Context adapter. */
|
||||
export interface TypertClientContextAdapter<Wire = unknown> {
|
||||
/**
|
||||
* Read the Remote identity represented by a calling Context.
|
||||
* @param ctx - Context rebound by the Cordis service tracker.
|
||||
* @returns the wire identity, or `undefined` when the Context has the wrong scope.
|
||||
* Read the identity represented by a live Client Context.
|
||||
* @param ctx - Client Context inspected by a scoped Remote caller.
|
||||
* @returns the wire identity, or `undefined` for another Context kind.
|
||||
*/
|
||||
identity(ctx: Context): Wire | undefined
|
||||
/**
|
||||
* Resolve a wire identity from the Client's currently materialized Contexts.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the Client Context, or `undefined` when unavailable.
|
||||
*/
|
||||
resolve(id: Wire): Context | undefined
|
||||
}
|
||||
|
||||
/** Host Context identity selected from the registered adapter set. */
|
||||
export interface TypertHostContextIdentity {
|
||||
/** Merge-declared Context kind whose adapter recognized the Context. */
|
||||
readonly kind: string
|
||||
/** Wire identity returned by that adapter. */
|
||||
readonly identity: unknown
|
||||
}
|
||||
|
||||
/** Notification emitted after a Typert runtime registry changes. */
|
||||
@@ -423,20 +508,20 @@ export interface TypertLookupRegistry {
|
||||
subscribe(listener: TypertRegistryListener): TypertDisposer
|
||||
}
|
||||
|
||||
/** Runtime registry for Host Context resolvers and Client Context binders. */
|
||||
/** Runtime registry for the Host and Client adapters of each Context kind. */
|
||||
export interface TypertContextRegistry {
|
||||
/**
|
||||
* Register a Host Context resolver.
|
||||
* Register a Host Context adapter.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param provider - owning package's Host resolver.
|
||||
* @returns disposer withdrawing the exact provider.
|
||||
* @param adapter - owning package's bidirectional Host projection.
|
||||
* @returns disposer withdrawing the exact adapter.
|
||||
*/
|
||||
registerHost<K extends StringKeyOf<TypertContextMap>>(
|
||||
key: K,
|
||||
provider: TypertHostContextProvider<TypertContextWire<TypertContextMap[K]>>,
|
||||
adapter: TypertHostContextAdapter<TypertContextWire<TypertContextMap[K]>>,
|
||||
): TypertDisposer
|
||||
/**
|
||||
* Override one Host Context key's identity policy for the calling fiber.
|
||||
* Override one Host Context key's resolution policy for the calling fiber.
|
||||
* Configuration may precede provider registration and restores the provider's default resolver on disposal.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param resolver - composition-owned resolver used by every Host Context lookup of this key.
|
||||
@@ -447,29 +532,36 @@ export interface TypertContextRegistry {
|
||||
resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
|
||||
): TypertDisposer
|
||||
/**
|
||||
* Register a Client Context identity binder.
|
||||
* Register a Client Context adapter.
|
||||
* @param key - merge-declared Context key.
|
||||
* @param binder - Client scope identity resolver.
|
||||
* @returns disposer withdrawing the exact binder.
|
||||
* @param adapter - owning package's bidirectional Client projection.
|
||||
* @returns disposer withdrawing the exact adapter.
|
||||
*/
|
||||
registerClient<K extends StringKeyOf<TypertContextMap>>(
|
||||
key: K,
|
||||
binder: TypertClientContextBinder<TypertContextWire<TypertContextMap[K]>>,
|
||||
adapter: TypertClientContextAdapter<TypertContextWire<TypertContextMap[K]>>,
|
||||
): TypertDisposer
|
||||
/**
|
||||
* Look up a Host Context resolver.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the provider, or `undefined` when absent.
|
||||
* Identify a live Host Context through the sole registered adapter set.
|
||||
* @param ctx - Context projected by a Host-to-Client scoped event.
|
||||
* @returns its kind and wire identity, or `undefined` when no adapter recognizes it.
|
||||
* @throws when more than one Context kind recognizes the same Context.
|
||||
*/
|
||||
getHost(key: string): TypertHostContextProvider | undefined
|
||||
identifyHost(ctx: Context): TypertHostContextIdentity | undefined
|
||||
/**
|
||||
* Look up a Client Context binder.
|
||||
* Look up a Host Context adapter.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the binder, or `undefined` when absent.
|
||||
* @returns the adapter, or `undefined` when absent.
|
||||
*/
|
||||
getClient(key: string): TypertClientContextBinder | undefined
|
||||
getHost(key: string): TypertHostContextAdapter | undefined
|
||||
/**
|
||||
* Observe later Context provider changes.
|
||||
* Look up a Client Context adapter.
|
||||
* @param key - descriptor Context key.
|
||||
* @returns the adapter, or `undefined` when absent.
|
||||
*/
|
||||
getClient(key: string): TypertClientContextAdapter | undefined
|
||||
/**
|
||||
* Observe later Context adapter changes.
|
||||
* @param listener - synchronous contained observer.
|
||||
* @returns disposer for this subscription.
|
||||
*/
|
||||
|
||||
@@ -8,11 +8,25 @@ import {
|
||||
Remote,
|
||||
RemoteScope,
|
||||
remoteMethods,
|
||||
type TypertClientEventListener,
|
||||
type TypertContext,
|
||||
type TypertForwardableEvent,
|
||||
type TypertForwardableEventEntry,
|
||||
type TypertLookup,
|
||||
type TypertRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
interface MetaFixtureSubject {
|
||||
readonly subjectId: string
|
||||
}
|
||||
|
||||
interface MetaFixtureRequest {
|
||||
readonly agent: MetaFixtureSubject
|
||||
readonly signal?: AbortSignal
|
||||
readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }]
|
||||
readonly transform: (subject: MetaFixtureSubject) => Promise<MetaFixtureSubject | undefined>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
@@ -25,6 +39,17 @@ declare module '@deepseek-ai/cordis' {
|
||||
* @param value - marker payload.
|
||||
*/
|
||||
'meta-fixture/scoped'(this: Context, value: string): void
|
||||
/**
|
||||
* Test-only scoped waterfall whose result can make the return trip.
|
||||
* @param value - marker payload.
|
||||
* @param next - delegates to the next listener.
|
||||
* @returns the claimed or delegated value.
|
||||
*/
|
||||
'meta-fixture/waterfall'(
|
||||
this: Context,
|
||||
request: MetaFixtureRequest,
|
||||
next: () => Promise<string>,
|
||||
): Promise<string>
|
||||
/**
|
||||
* Test-only answered event, whose result no one-way delivery can return.
|
||||
* @param value - marker payload.
|
||||
@@ -35,12 +60,16 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertLookupMap {
|
||||
metaFixture: TypertLookup<MetaFixtureSubject, string>
|
||||
}
|
||||
|
||||
interface TypertContextMap {
|
||||
metaFixture: TypertContext<string>
|
||||
}
|
||||
|
||||
interface TypertRemoteEventSelection extends
|
||||
Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {}
|
||||
Record<'meta-fixture/forwardable' | 'meta-fixture/waterfall' | 'meta-fixture/absent', true> {}
|
||||
}
|
||||
|
||||
describe('typert-protocol Remote declarations', () => {
|
||||
@@ -55,6 +84,11 @@ describe('typert-protocol Remote declarations', () => {
|
||||
return value
|
||||
}
|
||||
|
||||
@Remote({ mode: 'stream' })
|
||||
*watch(): Iterable<string> {
|
||||
yield 'value'
|
||||
}
|
||||
|
||||
@RemoteScope('metaFixture')
|
||||
scoped(value: string): string {
|
||||
return value
|
||||
@@ -78,6 +112,7 @@ describe('typert-protocol Remote declarations', () => {
|
||||
})
|
||||
expect(remoteMethods(goals)).toEqual([
|
||||
{ method: 'create', invocation: { kind: 'direct' } },
|
||||
{ method: 'watch', mode: 'stream', invocation: { kind: 'direct' } },
|
||||
{ method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } },
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
@@ -192,6 +227,8 @@ describe('typert-protocol Remote declarations', () => {
|
||||
expect(() => Remote('bad name')).toThrow('export name')
|
||||
expect(() => Remote('.')).toThrow('export name')
|
||||
expect(() => Remote('..')).toThrow('export name')
|
||||
expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode')
|
||||
expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode')
|
||||
expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key')
|
||||
expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name')
|
||||
|
||||
@@ -213,6 +250,12 @@ describe('typert-protocol Remote declarations', () => {
|
||||
Reflect.setPrototypeOf(prototypeLess, null)
|
||||
expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype')
|
||||
|
||||
const stream: Array<(this: object) => void> = []
|
||||
Remote({ mode: 'stream' })(method, methodContext('run', stream))
|
||||
const conflict = Object.create({}) as object
|
||||
direct[0]!.call(conflict)
|
||||
expect(() => { stream[0]!.call(conflict) }).toThrow('conflicting invocation markers')
|
||||
|
||||
class Service {
|
||||
run(): void {}
|
||||
}
|
||||
@@ -236,14 +279,41 @@ describe('typert-protocol Remote declarations', () => {
|
||||
expect(() => bindTypertRemote({}, 'goals', { namespace: 'api goals' })).toThrow('namespace')
|
||||
})
|
||||
|
||||
it('admits only one-way event shapes and only selected events that exist', () => {
|
||||
it('admits notifications and same-result scoped waterfalls selected from Cordis Events', () => {
|
||||
expectTypeOf<'meta-fixture/forwardable'>().toExtend<TypertForwardableEvent>()
|
||||
expectTypeOf<'meta-fixture/waterfall'>().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/waterfall'>().toExtend<TypertRemoteEvent>()
|
||||
expectTypeOf<'meta-fixture/scoped'>().not.toExtend<TypertRemoteEvent>()
|
||||
expectTypeOf<'meta-fixture/absent'>().not.toExtend<TypertRemoteEvent>()
|
||||
|
||||
expectTypeOf<{ event: 'meta-fixture/forwardable'; mode: 'emit' }>()
|
||||
.toExtend<TypertForwardableEventEntry>()
|
||||
expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'waterfall' }>()
|
||||
.toExtend<TypertForwardableEventEntry>()
|
||||
expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'emit' }>()
|
||||
.not.toExtend<TypertForwardableEventEntry>()
|
||||
})
|
||||
|
||||
it('derives Client Context arguments from the selected Cordis waterfall declaration', () => {
|
||||
type ExpectedListener = (
|
||||
this: Context,
|
||||
request: {
|
||||
readonly agent: Context
|
||||
readonly signal?: AbortSignal
|
||||
readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }]
|
||||
readonly transform: (subject: MetaFixtureSubject) => Promise<MetaFixtureSubject | undefined>
|
||||
},
|
||||
next: () => Promise<string>,
|
||||
) => Promise<string>
|
||||
|
||||
expectTypeOf<TypertClientEventListener<'meta-fixture/waterfall'>>()
|
||||
.toEqualTypeOf<ExpectedListener>()
|
||||
expectTypeOf<TypertClientEventListener<'meta-fixture/forwardable'>>()
|
||||
.toEqualTypeOf<(value: string) => void>()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypertClientContextBinder,
|
||||
TypertClientContextAdapter,
|
||||
TypertContextMap,
|
||||
TypertContextRegistry,
|
||||
TypertContextWire,
|
||||
TypertDisposer,
|
||||
TypertHostContextProvider,
|
||||
TypertHostContextAdapter,
|
||||
TypertHostContextResolver,
|
||||
TypertLocalRegistry,
|
||||
TypertLookupHost,
|
||||
@@ -334,9 +334,9 @@ function lookupDefinitionEquals(left: TypertLookupDefinition, right: TypertLooku
|
||||
}
|
||||
|
||||
class ContextStore {
|
||||
private readonly hosts = new Map<string, ProviderEntry<TypertHostContextProvider>>()
|
||||
private readonly hosts = new Map<string, ProviderEntry<TypertHostContextAdapter>>()
|
||||
private readonly hostResolvers = new Map<string, ProviderEntry<HostContextResolverEntry>>()
|
||||
private readonly clients = new Map<string, ProviderEntry<TypertClientContextBinder>>()
|
||||
private readonly clients = new Map<string, ProviderEntry<TypertClientContextAdapter>>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
@@ -347,34 +347,51 @@ class ContextStore {
|
||||
return {
|
||||
registerHost: <K extends Extract<keyof TypertContextMap, string>>(
|
||||
key: K,
|
||||
provider: TypertHostContextProvider<TypertContextWire<TypertContextMap[K]>>,
|
||||
) => this.registerHost(ctx, key, provider),
|
||||
adapter: TypertHostContextAdapter<TypertContextWire<TypertContextMap[K]>>,
|
||||
) => this.registerHost(ctx, key, adapter),
|
||||
configureHost: <K extends Extract<keyof TypertContextMap, string>>(
|
||||
key: K,
|
||||
resolver: TypertHostContextResolver<TypertContextWire<TypertContextMap[K]>>,
|
||||
) => this.configureHost(ctx, key, resolver),
|
||||
registerClient: <K extends Extract<keyof TypertContextMap, string>>(
|
||||
key: K,
|
||||
binder: TypertClientContextBinder<TypertContextWire<TypertContextMap[K]>>,
|
||||
) => this.registerClient(ctx, key, binder),
|
||||
adapter: TypertClientContextAdapter<TypertContextWire<TypertContextMap[K]>>,
|
||||
) => this.registerClient(ctx, key, adapter),
|
||||
identifyHost: context => this.identifyHost(context),
|
||||
getHost: key => this.getHost(key),
|
||||
getClient: key => this.clients.get(key)?.provider,
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
}
|
||||
|
||||
private getHost(key: string): TypertHostContextProvider | undefined {
|
||||
const provider = this.hosts.get(key)?.provider
|
||||
if (provider === undefined) return undefined
|
||||
private getHost(key: string): TypertHostContextAdapter | undefined {
|
||||
const adapter = this.hosts.get(key)?.provider
|
||||
if (adapter === undefined) return undefined
|
||||
const resolver = this.hostResolvers.get(key)?.provider
|
||||
if (resolver === undefined) return provider
|
||||
if (resolver === undefined) return adapter
|
||||
return {
|
||||
wire: provider.wire,
|
||||
wireTypeSymbol: provider.wireTypeSymbol,
|
||||
wire: adapter.wire,
|
||||
wireTypeSymbol: adapter.wireTypeSymbol,
|
||||
identity: context => adapter.identity(context),
|
||||
resolve: id => resolver.resolve(id),
|
||||
}
|
||||
}
|
||||
|
||||
private identifyHost(ctx: Context): ReturnType<TypertContextRegistry['identifyHost']> {
|
||||
let match: ReturnType<TypertContextRegistry['identifyHost']>
|
||||
for (const key of this.hosts.keys()) {
|
||||
const identity = this.getHost(key)?.identity(ctx)
|
||||
if (identity === undefined) continue
|
||||
if (match !== undefined) {
|
||||
throw new Error(
|
||||
`typert: Host Context is recognized by both ${JSON.stringify(match.kind)} and ${JSON.stringify(key)}`,
|
||||
)
|
||||
}
|
||||
match = { kind: key, identity }
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
private configureHost<Wire>(
|
||||
ctx: Context,
|
||||
key: string,
|
||||
@@ -399,16 +416,16 @@ class ContextStore {
|
||||
}, `typert.contexts.configureHost(${JSON.stringify(key)})`)
|
||||
}
|
||||
|
||||
private registerHost<Wire>(ctx: Context, key: string, provider: TypertHostContextProvider<Wire>): TypertDisposer {
|
||||
private registerHost<Wire>(ctx: Context, key: string, adapter: TypertHostContextAdapter<Wire>): TypertDisposer {
|
||||
validateSegment('Context key', key)
|
||||
validateWireName('Context wire field', provider.wire)
|
||||
validateNonempty('Context wire type symbol', provider.wireTypeSymbol)
|
||||
return this.registerProvider(ctx, this.hosts, 'host-context', key, provider)
|
||||
validateWireName('Context wire field', adapter.wire)
|
||||
validateNonempty('Context wire type symbol', adapter.wireTypeSymbol)
|
||||
return this.registerProvider(ctx, this.hosts, 'host-context', key, adapter)
|
||||
}
|
||||
|
||||
private registerClient<Wire>(ctx: Context, key: string, binder: TypertClientContextBinder<Wire>): TypertDisposer {
|
||||
private registerClient<Wire>(ctx: Context, key: string, adapter: TypertClientContextAdapter<Wire>): TypertDisposer {
|
||||
validateSegment('Context key', key)
|
||||
return this.registerProvider(ctx, this.clients, 'client-context', key, binder)
|
||||
return this.registerProvider(ctx, this.clients, 'client-context', key, adapter)
|
||||
}
|
||||
|
||||
private registerProvider<Provider>(
|
||||
@@ -484,7 +501,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract {
|
||||
return this.lookupStore.view(this.ctx)
|
||||
}
|
||||
|
||||
/** Host Context providers and Client Context binders. */
|
||||
/** Host and Client Context adapters. */
|
||||
get contexts(): TypertContextRegistry {
|
||||
return this.contextStore.view(this.ctx)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
|
||||
interface TypertContextMap {
|
||||
registryFixture: TypertContext<string>
|
||||
registryFixtureOther: TypertContext<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,10 +332,12 @@ describe('TypertRegistry', () => {
|
||||
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
identity: candidate => candidate === scoped ? object.id : undefined,
|
||||
resolve: id => id === object.id ? scoped : undefined,
|
||||
})
|
||||
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', {
|
||||
identity: candidate => candidate === scoped ? object.id : undefined,
|
||||
resolve: id => id === object.id ? scoped : undefined,
|
||||
})
|
||||
|
||||
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
|
||||
@@ -345,8 +348,15 @@ describe('TypertRegistry', () => {
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
}])
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.identity(scoped)).toBe('agent-1')
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')?.resolve('agent-1')).toBe(scoped)
|
||||
expect(ctx.typert.contexts.identifyHost(scoped)).toEqual({
|
||||
kind: 'registryFixture',
|
||||
identity: 'agent-1',
|
||||
})
|
||||
expect(ctx.typert.contexts.identifyHost(ctx)).toBeUndefined()
|
||||
|
||||
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
|
||||
expect(ctx.typert.lookups.keys()).toEqual([])
|
||||
@@ -355,6 +365,26 @@ describe('TypertRegistry', () => {
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a Host Context recognized by more than one registered kind', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const scoped = ctx.extend()
|
||||
ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture#AgentId',
|
||||
identity: candidate => candidate === scoped ? 'first' : undefined,
|
||||
resolve: () => undefined,
|
||||
})
|
||||
ctx.typert.contexts.registerHost('registryFixtureOther', {
|
||||
wire: 'otherAgentId',
|
||||
wireTypeSymbol: '@fixture#OtherAgentId',
|
||||
identity: candidate => candidate === scoped ? 'second' : undefined,
|
||||
resolve: () => undefined,
|
||||
})
|
||||
|
||||
expect(() => ctx.typert.contexts.identifyHost(scoped))
|
||||
.toThrow('recognized by both "registryFixture" and "registryFixtureOther"')
|
||||
})
|
||||
|
||||
it('configures an asynchronous lookup resolver independently of provider load order', async () => {
|
||||
const ctx = await makeCtx()
|
||||
const fallback = { id: 'fallback' }
|
||||
@@ -400,9 +430,11 @@ describe('TypertRegistry', () => {
|
||||
const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
identity: candidate => candidate === fallback ? 'fallback' : undefined,
|
||||
resolve: id => id === 'fallback' ? fallback : undefined,
|
||||
})
|
||||
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.identity(fallback)).toBe('fallback')
|
||||
expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured')
|
||||
|
||||
await disposeProvider()
|
||||
@@ -410,6 +442,7 @@ describe('TypertRegistry', () => {
|
||||
const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
identity: candidate => candidate === fallback ? 'fallback' : undefined,
|
||||
resolve: id => id === 'fallback' ? fallback : undefined,
|
||||
})
|
||||
await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured)
|
||||
@@ -438,9 +471,13 @@ describe('TypertRegistry', () => {
|
||||
const host = {
|
||||
wire: 'agentId',
|
||||
wireTypeSymbol: '@fixture#AgentId',
|
||||
identity: (_candidate: Context) => undefined,
|
||||
resolve: () => undefined,
|
||||
}
|
||||
const client = {
|
||||
identity: (_candidate: Context) => undefined,
|
||||
resolve: () => undefined,
|
||||
}
|
||||
const client = { identity: () => undefined }
|
||||
const disposeLookup = ctx.typert.lookups.register('fixture', lookup)
|
||||
const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host)
|
||||
const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client)
|
||||
|
||||
Reference in New Issue
Block a user