feat(api-gateway): unify Remote streams and events

This commit is contained in:
imccyu
2026-08-23 16:16:01 +08:00
parent 4ba47e665b
commit 3d6d595d79
48 changed files with 7926 additions and 546 deletions
@@ -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')
})
}
+191 -98
View File
@@ -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
}
+564 -52
View File
@@ -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}`
}
+399
View File
@@ -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)
}
}
+188
View File
@@ -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'))
}
+98
View File
@@ -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)
})
})
}
+1 -1
View File
@@ -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
+3 -12
View File
@@ -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,
})
})
+31 -30
View File
@@ -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' {
+19
View File
@@ -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'
+5 -1
View File
@@ -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>
}
}
+47 -18
View File
@@ -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 {
+6 -3
View File
@@ -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}>>`
}
}
+1
View File
@@ -131,6 +131,7 @@ export interface InvocationModel {
readonly namespace: string
readonly method: string
readonly implementation?: string
readonly mode?: 'stream'
readonly invocation:
| { readonly kind: 'direct' }
| {
@@ -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 */
+70 -25
View File
@@ -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))
+151 -59
View File
@@ -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>()
})
})
+38 -21
View File
@@ -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)
}
+38 -1
View File
@@ -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)