1546 lines
62 KiB
TypeScript
1546 lines
62 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { once } from 'node:events'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import WebSocket, { type RawData } from 'ws'
|
|
import { Context, symbols } from '@deepseek-ai/cordis'
|
|
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
|
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
|
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
|
import {
|
|
Remote,
|
|
remoteErrorOf,
|
|
TypertRemoteService,
|
|
type InvocationDescriptor,
|
|
type PeerScope,
|
|
type RemoteInvocation,
|
|
type RemoteStream,
|
|
type TypertContextMap,
|
|
type TypertContextWire,
|
|
RemoteError,
|
|
} from '@deepseek-ai/dsh-typert-protocol'
|
|
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
|
|
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
interface RemoteErrorDetailsMap {
|
|
'fixture/rejected': { readonly retryable: boolean }
|
|
'fixture/broken': { readonly count: bigint }
|
|
}
|
|
}
|
|
import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts'
|
|
import TypertGatewayService, {
|
|
TypertGatewayError,
|
|
type Config as GatewayConfig,
|
|
type TypertRemoteEventDispatch,
|
|
type TypertRemoteEventInvocation,
|
|
type TypertRemoteEventOutcome,
|
|
} from '@deepseek-ai/dsh-api-gateway'
|
|
import { z } from 'zod'
|
|
import type {
|
|
RemoteEventClientId,
|
|
RemoteEventInvocationFrame,
|
|
} from '../src/stream-protocol.ts'
|
|
|
|
vi.mock('node:crypto', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:crypto')>()
|
|
return { ...actual, randomUUID: vi.fn(actual.randomUUID) }
|
|
})
|
|
|
|
const randomUuid = vi.mocked(randomUUID)
|
|
const REMOTE_HOST = { home: '/home/fixture' } as const
|
|
type AgentWireId = TypertContextWire<TypertContextMap['agent']>
|
|
const agentId = (value: string): AgentWireId => value as AgentWireId
|
|
|
|
class FeedService extends TypertRemoteService {
|
|
readonly signals: AbortSignal[] = []
|
|
readonly peeked: unknown[] = []
|
|
readonly peers: PeerScope[] = []
|
|
leftover: AsyncIterator<string> | undefined
|
|
returns = 0
|
|
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'feed')
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
async *follow(label: string, signal: AbortSignal): AsyncIterable<string> {
|
|
this.signals.push(signal)
|
|
try {
|
|
yield `${label}:ready`
|
|
await new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
} finally {
|
|
this.returns += 1
|
|
}
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
*sync(label: string): Iterable<string> {
|
|
yield `${label}:one`
|
|
yield `${label}:two`
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
*invalid(): Iterable<string> {
|
|
yield 42 as unknown as string
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
*nonJson(): Iterable<unknown> {
|
|
yield 1n
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
missing(): Iterable<string> {
|
|
return null as unknown as Iterable<string>
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
*src(label: string): Iterable<string> {
|
|
yield `${label}:src`
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
abortBeforeOpen(signal: AbortSignal): Iterable<string> {
|
|
if (signal.aborted) throw new Error('fixture observed pre-open cancellation')
|
|
return []
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
reject(): Iterable<string> {
|
|
throw new RemoteError('fixture/rejected', 'fixture rejected the stream', { retryable: false })
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
rejectWithNonJsonDetails(): Iterable<string> {
|
|
throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n })
|
|
}
|
|
|
|
@Remote({ mode: 'stream' })
|
|
async *echo(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
|
this.signals.push(signal)
|
|
const invocation = this.invocation()
|
|
this.peers.push(invocation.peer)
|
|
try {
|
|
for await (const item of invocation.uplink<string>()) yield `${prefix}${item}`
|
|
} finally {
|
|
this.returns += 1
|
|
}
|
|
}
|
|
|
|
/** Hides the uplink failure so the test proves the Gateway still fails the stream. */
|
|
@Remote({ mode: 'stream' })
|
|
async *swallow(signal: AbortSignal): RemoteStream<string, string> {
|
|
try {
|
|
for await (const item of this.invocation().uplink<string>()) yield item
|
|
} catch {
|
|
// The fixture keeps yielding after the rejected item on purpose.
|
|
}
|
|
yield 'swallowed'
|
|
await abortOf(signal)
|
|
}
|
|
|
|
/** Never takes its uplink. */
|
|
@Remote({ mode: 'stream' })
|
|
async *ignore(prefix: string): RemoteStream<string, string> {
|
|
yield `${prefix}:one`
|
|
yield `${prefix}:two`
|
|
}
|
|
|
|
/** Keeps the downlink open without reading, and retains the uplink iterator for post-stream reads. */
|
|
@Remote({ mode: 'stream' })
|
|
async *hold(signal: AbortSignal): RemoteStream<string, string> {
|
|
this.leftover = this.invocation().uplink<string>()[Symbol.asyncIterator]()
|
|
yield 'held'
|
|
await abortOf(signal)
|
|
}
|
|
|
|
/** Reads before yielding so cancellation races the pending read, then reads once more after the abort. */
|
|
@Remote({ mode: 'stream' })
|
|
async *peek(signal: AbortSignal): RemoteStream<string, string> {
|
|
const iterator = this.invocation().uplink<string>()[Symbol.asyncIterator]()
|
|
const pending = iterator.next().then(result => result, (error: unknown) => error)
|
|
yield 'ready'
|
|
await abortOf(signal)
|
|
this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error))
|
|
}
|
|
|
|
/** Yields once, then blocks on the uplink until the Gateway closes it. */
|
|
@Remote({ mode: 'stream' })
|
|
async *drain(): RemoteStream<string, string> {
|
|
yield 'ready'
|
|
for await (const item of this.invocation().uplink<string>()) yield item
|
|
}
|
|
|
|
/** Reads the uplink to its end and then again: an ended uplink reports done on every later read. */
|
|
@Remote({ mode: 'stream' })
|
|
async *rereads(): RemoteStream<string, string> {
|
|
const uplink = this.invocation().uplink<string>()
|
|
for await (const item of uplink) yield item
|
|
for await (const item of uplink) yield `again:${item}`
|
|
yield 'done'
|
|
}
|
|
|
|
/** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */
|
|
@Remote({ mode: 'stream' })
|
|
async *context(label: string): RemoteStream<string> {
|
|
const invocation = this.invocation()
|
|
this.peers.push(invocation.peer)
|
|
const { namespace, method, args } = invocation.request
|
|
yield `${namespace}/${method}:${invocation.service}:${JSON.stringify(args)}:${label}`
|
|
for await (const item of invocation.uplink()) yield `raw:${JSON.stringify(item)}`
|
|
try {
|
|
invocation.uplink()
|
|
} catch (error) {
|
|
yield error instanceof Error ? error.message : String(error)
|
|
}
|
|
}
|
|
|
|
/** SRC-discovered: no descriptor, so uplink items arrive as `unknown`. */
|
|
@Remote({ mode: 'stream' })
|
|
async *srcEcho(prefix: string, signal: AbortSignal): RemoteStream<string, string> {
|
|
void signal
|
|
for await (const item of this.invocation().uplink()) yield `${prefix}${String(item)}`
|
|
}
|
|
|
|
unary(label: string): string {
|
|
return label
|
|
}
|
|
|
|
/** Reads the uplink inside a unary call: items arrive only while the method runs. */
|
|
async unaryUplink(): Promise<string[]> {
|
|
const items: string[] = []
|
|
for await (const item of this.invocation().uplink<string>()) items.push(item)
|
|
return items
|
|
}
|
|
|
|
/** What `ctx.invocation` reads on the Service's own Context, outside any Remote call. */
|
|
invocationOutsideCall(): RemoteInvocation | undefined {
|
|
return this.ctx.invocation
|
|
}
|
|
|
|
private invocation(): RemoteInvocation {
|
|
const invocation = this.ctx.invocation
|
|
if (invocation === undefined) throw new Error('fixture method ran outside a Remote call')
|
|
return invocation
|
|
}
|
|
}
|
|
|
|
const roots: Context[] = []
|
|
|
|
class RemoteEventSourceProbe {
|
|
readonly source = (signal: AbortSignal): AsyncIterable<TypertRemoteEventDispatch> => {
|
|
this.signal = signal
|
|
return this.iterate(signal)
|
|
}
|
|
|
|
signal: AbortSignal | undefined
|
|
private readonly dispatches: TypertRemoteEventDispatch[] = []
|
|
private wake: (() => void) | undefined
|
|
|
|
push(dispatch: TypertRemoteEventDispatch): void {
|
|
this.dispatches.push(dispatch)
|
|
this.wake?.()
|
|
this.wake = undefined
|
|
}
|
|
|
|
private async *iterate(signal: AbortSignal): AsyncGenerator<TypertRemoteEventDispatch> {
|
|
const aborted = (): void => {
|
|
this.wake?.()
|
|
this.wake = undefined
|
|
}
|
|
signal.addEventListener('abort', aborted, { once: true })
|
|
try {
|
|
while (!signal.aborted) {
|
|
while (this.dispatches.length > 0) {
|
|
yield this.dispatches.shift() as TypertRemoteEventDispatch
|
|
}
|
|
if (signal.aborted) return
|
|
await new Promise<void>((resolve) => { this.wake = resolve })
|
|
this.wake = undefined
|
|
}
|
|
} finally {
|
|
signal.removeEventListener('abort', aborted)
|
|
}
|
|
}
|
|
}
|
|
|
|
interface PendingInvocationProbe {
|
|
readonly dispatch: TypertRemoteEventInvocation
|
|
readonly outcome: Promise<TypertRemoteEventOutcome>
|
|
readonly resolve: (outcome: TypertRemoteEventOutcome) => void
|
|
readonly reject: (reason: unknown) => void
|
|
}
|
|
|
|
function pendingInvocation(
|
|
context: Context,
|
|
signal?: AbortSignal,
|
|
prompt = 'ship',
|
|
identity: unknown = agentId('agent-1'),
|
|
): PendingInvocationProbe {
|
|
const subject = { ctx: context }
|
|
const settled = Promise.withResolvers<TypertRemoteEventOutcome>()
|
|
const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => {
|
|
settled.resolve(outcome)
|
|
})
|
|
const reject = vi.fn((reason: unknown) => {
|
|
settled.reject(reason)
|
|
})
|
|
return {
|
|
dispatch: {
|
|
event: 'fixture/approval',
|
|
request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) },
|
|
context: { value: context, subject, agentId: identity as string },
|
|
resolve,
|
|
reject,
|
|
},
|
|
outcome: settled.promise,
|
|
resolve,
|
|
reject,
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
randomUuid.mockClear()
|
|
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
})
|
|
|
|
describe('Typert Remote streams', () => {
|
|
it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => {
|
|
expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 })
|
|
expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }))
|
|
.toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })
|
|
for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) {
|
|
expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow()
|
|
}
|
|
for (const streamInboxBytes of [0, 1.5]) {
|
|
expect(() => TypertGatewayService.Config({ streamInboxBytes })).toThrow()
|
|
}
|
|
})
|
|
|
|
it('opens decoded carrier payloads through the in-process wire adapter', async () => {
|
|
const { ctx } = await setup(false)
|
|
const source = await ctx.typertGateway.wireStream.open(
|
|
'feed/sync',
|
|
{ args: { label: 'wire' } },
|
|
toAsync([]),
|
|
undefined,
|
|
new AbortController().signal,
|
|
)
|
|
await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two'])
|
|
|
|
const echoed = await ctx.typertGateway.wireStream.open(
|
|
'feed/echo',
|
|
{ args: { prefix: 'wire:' } },
|
|
toAsync(['a', 'b']),
|
|
undefined,
|
|
new AbortController().signal,
|
|
)
|
|
await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b'])
|
|
})
|
|
|
|
it('delivers uplink items through the in-process carrier and ends on half-close', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']),
|
|
}))).resolves.toEqual(['> a', '> b'])
|
|
expect(service.returns).toBe(1)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'echo', args: { prefix: 'x' },
|
|
}))).resolves.toEqual([])
|
|
await expect(ctx.typertGateway.invoke({
|
|
namespace: 'feed', method: 'echo', args: { prefix: 'x' },
|
|
})).rejects.toMatchObject({
|
|
code: 'gateway/signature-invalid',
|
|
message: 'typert gateway: feed/echo: stream Remote methods must be opened through the stream carrier',
|
|
})
|
|
|
|
// The caller's iterator is released even when its own return() rejects.
|
|
const returned = vi.fn(async (): Promise<IteratorResult<string>> => {
|
|
throw new Error('fixture release failure')
|
|
})
|
|
const unread: AsyncIterable<string> = {
|
|
[Symbol.asyncIterator]: () => ({
|
|
next: () => new Promise<IteratorResult<string>>(() => {}),
|
|
return: returned,
|
|
}),
|
|
}
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread,
|
|
}))).resolves.toEqual(['i:one', 'i:two'])
|
|
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
|
})
|
|
|
|
it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
const rejected = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync<unknown>(['ok', 1]),
|
|
})
|
|
const iterator = rejected[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ok' })
|
|
await expect(iterator.next()).rejects.toMatchObject({
|
|
code: 'gateway/input-invalid',
|
|
details: { endpoint: 'feed/echo', field: 'uplink' },
|
|
})
|
|
const signal = service.signals.at(-1)
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(remoteErrorOf(signal?.reason)?.code).toBe('gateway/input-invalid')
|
|
expect(service.returns).toBe(1)
|
|
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync<unknown>([1]),
|
|
}))).rejects.toMatchObject({ code: 'gateway/input-invalid' })
|
|
})
|
|
|
|
it('ends a pending uplink read on cancellation and reports later reads as cancelled', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
const abort = new AbortController()
|
|
const source = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal,
|
|
})
|
|
const iterator = source[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' })
|
|
const pending = iterator.next()
|
|
abort.abort(new Error('fixture cancellation'))
|
|
await expect(pending).rejects.toThrow('Remote invocation "feed/peek" was aborted')
|
|
await vi.waitFor(() => { expect(service.peeked).toHaveLength(2) })
|
|
expect(service.peeked.map(outcome => remoteErrorOf(outcome)?.code))
|
|
.toEqual(['gateway/cancelled', 'gateway/cancelled'])
|
|
})
|
|
|
|
it('drops unread uplink items once the method finishes', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
const abort = new AbortController()
|
|
const source = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal,
|
|
})
|
|
const iterator = source[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
|
const pending = iterator.next()
|
|
abort.abort(new Error('fixture cancellation'))
|
|
await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted')
|
|
const leftover = service.leftover
|
|
if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator')
|
|
// The unread item is gone; a cancelled stream reports the cancellation on every later read.
|
|
await expect(leftover.next()).rejects.toMatchObject({ code: 'gateway/cancelled' })
|
|
await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync<unknown>([1, { nested: true }]),
|
|
}))).resolves.toEqual([
|
|
'feed/context:feed:{"label":"ctx"}:ctx',
|
|
'raw:1',
|
|
'raw:{"nested":true}',
|
|
'typert gateway: feed/context: invocation.uplink() is available once per call',
|
|
])
|
|
expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/)
|
|
expect(service.invocationOutsideCall()).toBeUndefined()
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'context', args: { label: 'again' },
|
|
}))).resolves.toHaveLength(2)
|
|
expect(service.peers.at(-1)).toBe(service.peers.at(-2))
|
|
await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined()
|
|
|
|
// Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends.
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']),
|
|
}))).resolves.toEqual(['closed:one', 'closed:two'])
|
|
|
|
// Without a codec an item still has to be a JSON-safe value.
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync<unknown>([1n]),
|
|
}))).rejects.toMatchObject({
|
|
code: 'gateway/input-invalid',
|
|
details: { endpoint: 'feed/context', field: 'uplink' },
|
|
})
|
|
})
|
|
|
|
it('releases a taken uplink source even when its return() rejects', async () => {
|
|
const { ctx } = await setup(false)
|
|
const abort = new AbortController()
|
|
const returned = vi.fn(async (): Promise<IteratorResult<string>> => {
|
|
throw new Error('fixture release failure')
|
|
})
|
|
const stuck: AsyncIterable<string> = {
|
|
[Symbol.asyncIterator]: () => ({
|
|
next: () => new Promise<IteratorResult<string>>(() => {}),
|
|
return: returned,
|
|
}),
|
|
}
|
|
const source = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal,
|
|
})
|
|
const iterator = source[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' })
|
|
const pending = iterator.next()
|
|
abort.abort(new Error('fixture cancellation'))
|
|
await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted')
|
|
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
|
})
|
|
|
|
it('closes the uplink before returning a method blocked on it', async () => {
|
|
const { ctx } = await setup(false)
|
|
const source = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(),
|
|
})
|
|
const iterator = source[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' })
|
|
await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it('reports done on every read after the uplink ended', async () => {
|
|
const { ctx } = await setup(false)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']),
|
|
}))).resolves.toEqual(['a', 'b', 'done'])
|
|
})
|
|
|
|
it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => {
|
|
const { ctx } = await setup(false)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync<unknown>([undefined, 'x']),
|
|
}))).resolves.toEqual([
|
|
'feed/context:feed:{"label":"absent"}:absent',
|
|
'raw:undefined',
|
|
'raw:"x"',
|
|
'typert gateway: feed/context: invocation.uplink() is available once per call',
|
|
])
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync<unknown>([undefined]),
|
|
}))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } })
|
|
})
|
|
|
|
it('releases the uplink when a unary method is opened through the stream carrier', async () => {
|
|
const { ctx } = await setup(false)
|
|
const returned = vi.fn(async (): Promise<IteratorResult<string>> => ({ value: undefined, done: true }))
|
|
const unread: AsyncIterable<string> = {
|
|
[Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<string>>(() => {}), return: returned }),
|
|
}
|
|
await expect(ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread,
|
|
})).rejects.toMatchObject({ code: 'gateway/signature-invalid' })
|
|
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
|
})
|
|
|
|
it('lets a unary method read uplink items while it runs', async () => {
|
|
const { ctx } = await setup(false)
|
|
await expect(ctx.typertGateway.invoke({
|
|
namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']),
|
|
})).resolves.toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('derives SRC descriptors whose uplink arrives without a codec', async () => {
|
|
const { ctx } = await setup(false)
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync<unknown>(['a', 2]),
|
|
}))).resolves.toEqual(['s:a', 's:2'])
|
|
})
|
|
|
|
it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => {
|
|
const { ctx, service } = await setup(true)
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
|
|
sendOpen(socket, 'echo', 'feed/echo', { prefix: '> ' })
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' }))
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' }))
|
|
socket.send(JSON.stringify({ type: 'end', streamId: 'echo' }))
|
|
await vi.waitFor(() => {
|
|
expect(frames.filter(frame => frame.streamId === 'echo')).toEqual([
|
|
{ type: 'item', streamId: 'echo', value: '> a' },
|
|
{ type: 'item', streamId: 'echo', value: '> b' },
|
|
{ type: 'end', streamId: 'echo' },
|
|
])
|
|
})
|
|
expect(service.returns).toBe(1)
|
|
expect(service.peers.at(-1)).toBe(ctx.connection.operator)
|
|
|
|
sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' })
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 }))
|
|
await vi.waitFor(() => {
|
|
expect(frames.find(frame => frame.streamId === 'rejected')).toMatchObject({
|
|
type: 'error',
|
|
error: { code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } },
|
|
})
|
|
})
|
|
|
|
// An item frame without value is a top-level undefined item.
|
|
sendOpen(socket, 'absent', 'feed/context', { label: 'w' })
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'absent' }))
|
|
await vi.waitFor(() => {
|
|
expect(frames).toContainEqual({ type: 'item', streamId: 'absent', value: 'raw:undefined' })
|
|
})
|
|
|
|
sendOpen(socket, 'late', 'feed/hold', {})
|
|
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) })
|
|
socket.send(JSON.stringify({ type: 'end', streamId: 'late' }))
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'late', value: 'after end' }))
|
|
await vi.waitFor(() => {
|
|
expect(frames.find(frame => frame.streamId === 'late' && frame.type === 'error')).toMatchObject({
|
|
error: { code: 'gateway/protocol', details: { endpoint: 'feed/hold' } },
|
|
})
|
|
})
|
|
expect(socket.readyState).toBe(WebSocket.OPEN)
|
|
socket.close()
|
|
await once(socket, 'close')
|
|
})
|
|
|
|
it('fails a stream whose buffered uplink exceeds the configured inbox bytes', async () => {
|
|
const { ctx } = await setup(true, { streamInboxBytes: 64 })
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
|
|
sendOpen(socket, 'big', 'feed/hold', {})
|
|
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'big', value: 'held' }) })
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'big', value: 'x'.repeat(128) }))
|
|
await vi.waitFor(() => {
|
|
expect(frames.find(frame => frame.streamId === 'big' && frame.type === 'error')).toMatchObject({
|
|
error: { code: 'gateway/uplink-overflow', details: { endpoint: 'feed/hold' } },
|
|
})
|
|
})
|
|
socket.close()
|
|
await once(socket, 'close')
|
|
})
|
|
|
|
it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => {
|
|
const { ctx, service } = await setup(false)
|
|
const abort = new AbortController()
|
|
const source = await ctx.typertGateway.stream({
|
|
namespace: 'feed',
|
|
method: 'follow',
|
|
args: { label: 'a' },
|
|
signal: abort.signal,
|
|
})
|
|
const iterator = source[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' })
|
|
const pending = iterator.next()
|
|
abort.abort(new Error('fixture cancellation'))
|
|
await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted')
|
|
expect(service.signals).toEqual([abort.signal])
|
|
expect(service.returns).toBe(1)
|
|
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'sync', args: { label: 'b' },
|
|
}))).resolves.toEqual(['b:one', 'b:two'])
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'invalid', args: {},
|
|
}))).resolves.toEqual([42])
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'nonJson', args: {},
|
|
}))).resolves.toEqual([1n])
|
|
await expect(ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'missing', args: {},
|
|
})).rejects.toMatchObject({ code: 'gateway/result-invalid' })
|
|
|
|
await expect(collect(await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'src', args: { label: 'c' },
|
|
}))).resolves.toEqual(['c:src'])
|
|
|
|
const abortedBeforeOpen = new AbortController()
|
|
abortedBeforeOpen.abort(new Error('cancelled before open'))
|
|
await expect(ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal,
|
|
})).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted')
|
|
|
|
const abortedBeforeIteration = new AbortController()
|
|
abortedBeforeIteration.abort(new Error('cancelled before iteration'))
|
|
const preCancelled = await ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal,
|
|
})
|
|
await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted')
|
|
})
|
|
|
|
it('keeps unary and stream invocation modes distinct', async () => {
|
|
const { ctx } = await setup(false)
|
|
await expect(ctx.typertGateway.invoke({
|
|
namespace: 'feed', method: 'sync', args: { label: 'a' },
|
|
})).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial<TypertGatewayError>)
|
|
await expect(ctx.typertGateway.stream({
|
|
namespace: 'feed', method: 'unary', args: { label: 'a' },
|
|
})).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial<TypertGatewayError>)
|
|
})
|
|
|
|
it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => {
|
|
const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 })
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
const ping = once(socket, 'ping')
|
|
await once(socket, 'open')
|
|
expect((await ping)[0]).toEqual(Buffer.alloc(0))
|
|
|
|
socket.close()
|
|
await once(socket, 'close')
|
|
})
|
|
|
|
it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => {
|
|
const { ctx, service } = await setup(true)
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
|
|
sendOpen(socket, 'a', 'feed/follow', { label: 'a' })
|
|
sendOpen(socket, 'b', 'feed/follow', { label: 'b' })
|
|
await vi.waitFor(() => {
|
|
expect(frames).toEqual(expect.arrayContaining([
|
|
{ type: 'item', streamId: 'a', value: 'a:ready' },
|
|
{ type: 'item', streamId: 'b', value: 'b:ready' },
|
|
]))
|
|
})
|
|
expect(service.signals.map(signal => signal.aborted)).toEqual([false, false])
|
|
expect(service.returns).toBe(0)
|
|
|
|
socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' }))
|
|
await vi.waitFor(() => { expect(service.returns).toBe(1) })
|
|
expect(service.signals[0]?.aborted).toBe(true)
|
|
expect(service.signals[1]?.aborted).toBe(false)
|
|
|
|
sendOpen(socket, 'sync', 'feed/sync', { label: 's' })
|
|
sendOpen(socket, 'invalid', 'feed/invalid', {})
|
|
sendOpen(socket, 'non-json', 'feed/nonJson', {})
|
|
sendOpen(socket, 'rejected', 'feed/reject', {})
|
|
await vi.waitFor(() => {
|
|
expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([
|
|
{ type: 'item', streamId: 'sync', value: 's:one' },
|
|
{ type: 'item', streamId: 'sync', value: 's:two' },
|
|
{ type: 'end', streamId: 'sync' },
|
|
])
|
|
expect(frames.filter(frame => frame.streamId === 'invalid')).toEqual([
|
|
{ type: 'item', streamId: 'invalid', value: 42 },
|
|
{ type: 'end', streamId: 'invalid' },
|
|
])
|
|
expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({
|
|
type: 'error', error: { code: 'gateway/internal' },
|
|
})
|
|
expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({
|
|
type: 'error',
|
|
streamId: 'rejected',
|
|
error: {
|
|
code: 'fixture/rejected',
|
|
message: 'fixture rejected the stream',
|
|
details: { retryable: false },
|
|
},
|
|
})
|
|
})
|
|
|
|
const closed = once(socket, 'close')
|
|
sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {})
|
|
const closeEvent = await closed
|
|
expect(closeEvent[0]).toBe(1011)
|
|
expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered')
|
|
await vi.waitFor(() => { expect(service.returns).toBe(2) })
|
|
expect(service.signals[1]?.aborted).toBe(true)
|
|
})
|
|
|
|
it('keeps uplink frames to a Gateway-owned stream out of the bounded inbox', async () => {
|
|
const { ctx } = await setup(true, { streamInboxBytes: 64 })
|
|
const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => (async function *() {
|
|
await new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
})()
|
|
ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
|
|
|
// An in-process carrier's uplink is returned as soon as the Gateway-owned stream opens.
|
|
const returned = vi.fn(async (): Promise<IteratorResult<unknown>> => ({ value: undefined, done: true }))
|
|
const idle: AsyncIterable<unknown> = {
|
|
[Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<unknown>>(() => {}), return: returned }),
|
|
}
|
|
const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal)
|
|
await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() })
|
|
await events[Symbol.asyncIterator]().return?.()
|
|
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
sendOpen(socket, 'events', '$events', {})
|
|
await vi.waitFor(() => { expect(frames.filter(frame => frame.streamId === 'events')).toHaveLength(1) })
|
|
for (let index = 0; index < 4; index += 1) {
|
|
socket.send(JSON.stringify({ type: 'item', streamId: 'events', value: 'x'.repeat(40) }))
|
|
}
|
|
// A Remote stream opened afterwards on the same socket shows the flood was processed and harmed nothing.
|
|
sendOpen(socket, 'after', 'feed/sync', { label: 'after' })
|
|
await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'end', streamId: 'after' }) })
|
|
expect(frames.filter(frame => frame.streamId === 'events').map(frame => frame.type)).toEqual(['item'])
|
|
socket.close()
|
|
await once(socket, 'close')
|
|
})
|
|
|
|
it('carries the registered Remote event source and withdraws its active stream', async () => {
|
|
const { ctx } = await setup(true)
|
|
let sourceSignal: AbortSignal | undefined
|
|
const sourceClosed = vi.fn()
|
|
const publish = Promise.withResolvers<undefined>()
|
|
const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => {
|
|
sourceSignal = signal
|
|
return (async function *() {
|
|
try {
|
|
await publish.promise
|
|
yield { event: 'fixture/changed', args: ['settings'] }
|
|
await new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
} finally {
|
|
sourceClosed()
|
|
}
|
|
})()
|
|
}
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
|
expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) })
|
|
.toThrow('forwarded Remote event source is already registered')
|
|
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
sendOpen(socket, 'events', '$events', {})
|
|
|
|
await vi.waitFor(() => {
|
|
const eventFrames = frames.filter(frame => frame.streamId === 'events')
|
|
expect(eventFrames).toHaveLength(1)
|
|
expect(eventFrames[0]).toMatchObject({
|
|
type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST },
|
|
})
|
|
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
|
})
|
|
publish.resolve(undefined)
|
|
await vi.waitFor(() => {
|
|
const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2)
|
|
expect(eventFrames).toHaveLength(2)
|
|
expect(eventFrames[0]).toMatchObject({
|
|
type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST },
|
|
})
|
|
expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string')
|
|
expect(eventFrames[1]).toEqual({
|
|
type: 'item', streamId: 'events', value: {
|
|
type: 'emit', event: 'fixture/changed', args: ['settings'],
|
|
},
|
|
})
|
|
})
|
|
expect(sourceSignal?.aborted).toBe(false)
|
|
|
|
await unregister()
|
|
expect(sourceClosed).toHaveBeenCalledOnce()
|
|
await vi.waitFor(() => {
|
|
expect(sourceSignal?.aborted).toBe(true)
|
|
expect(frames).toContainEqual({ type: 'end', streamId: 'events' })
|
|
})
|
|
|
|
const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
|
await unregister()
|
|
expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) })
|
|
.toThrow('forwarded Remote event source is already registered')
|
|
await unregisterReplacement()
|
|
socket.close()
|
|
})
|
|
|
|
it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => {
|
|
const { ctx } = await setup(false)
|
|
const publish = Promise.withResolvers<undefined>()
|
|
const agent = ctx.extend()
|
|
const pending = pendingInvocation(agent)
|
|
const source = (): AsyncIterable<TypertRemoteEventDispatch> => (async function* () {
|
|
await publish.promise
|
|
yield pending.dispatch
|
|
})()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST)
|
|
const rejected = expect(pending.outcome).rejects.toThrow(
|
|
'forwarded Remote event source was removed',
|
|
)
|
|
|
|
publish.resolve(undefined)
|
|
await unregister()
|
|
|
|
await rejected
|
|
expect(pending.reject).toHaveBeenCalledTimes(1)
|
|
expect(pending.resolve).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('cancels a pending waterfall when its source rejects during removal', async () => {
|
|
const { ctx } = await setup(true)
|
|
const agent = ctx.extend()
|
|
const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-removal'))
|
|
const rejected = expect(pending.outcome).rejects.toThrow(
|
|
'forwarded Remote event source was removed',
|
|
)
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () {
|
|
yield pending.dispatch
|
|
await new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
throw new Error('fixture source rejected during removal')
|
|
})(), REMOTE_HOST)
|
|
const client = await openEventClient(ctx, 'events-removal')
|
|
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
|
|
|
await unregister()
|
|
await rejected
|
|
expect(pending.reject).toHaveBeenCalledTimes(1)
|
|
expect(pending.resolve).not.toHaveBeenCalled()
|
|
await vi.waitFor(() => {
|
|
expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId })
|
|
})
|
|
client.socket.close()
|
|
})
|
|
|
|
it('rejects malformed scoped invocations and delegates a released Context', async () => {
|
|
const { ctx } = await setup(false)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
|
|
for (const event of [42, ''] as const) {
|
|
const invalidName = pendingInvocation(ctx)
|
|
const rejected = expect(invalidName.outcome).rejects.toThrow(
|
|
'Remote event name must be a nonempty string',
|
|
)
|
|
source.push({
|
|
...invalidName.dispatch,
|
|
event: event as unknown as string,
|
|
})
|
|
await rejected
|
|
}
|
|
|
|
let selected = ctx.extend()
|
|
const nonJsonIdentity = pendingInvocation(selected, undefined, 'ship', 1n)
|
|
const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow(
|
|
'require a non-empty Agent identity',
|
|
)
|
|
source.push(nonJsonIdentity.dispatch)
|
|
await nonJsonRejected
|
|
|
|
const invalidRequest = pendingInvocation(selected, undefined, 'ship', agentId('agent-invalid-request'))
|
|
const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow(
|
|
'must carry its scoped Agent directly',
|
|
)
|
|
source.push({
|
|
...invalidRequest.dispatch,
|
|
request: {},
|
|
})
|
|
await invalidRequestRejected
|
|
|
|
const staleFiber = ctx.plugin(() => {})
|
|
await staleFiber
|
|
selected = staleFiber.ctx
|
|
await staleFiber.dispose()
|
|
const stale = pendingInvocation(selected, undefined, 'ship', agentId('agent-stale'))
|
|
source.push(stale.dispatch)
|
|
await expect(stale.outcome).resolves.toEqual({ kind: 'next' })
|
|
expect(stale.reject).not.toHaveBeenCalled()
|
|
|
|
selected = ctx.extend()
|
|
const abort = new AbortController()
|
|
abort.abort('fixture non-error cancellation')
|
|
const cancelled = pendingInvocation(selected, abort.signal, 'ship', agentId('agent-cancelled'))
|
|
const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({
|
|
message: 'typert gateway: Remote event was cancelled',
|
|
cause: 'fixture non-error cancellation',
|
|
})
|
|
source.push(cancelled.dispatch)
|
|
await cancelledOutcome
|
|
|
|
await unregister()
|
|
})
|
|
|
|
it('rejects notification arguments that are not lossless JSON arrays', async () => {
|
|
const { ctx } = await setup(false)
|
|
const frames = [
|
|
{ event: 'fixture/changed', args: {} },
|
|
{ event: 'fixture/changed', args: [1n] },
|
|
]
|
|
for (const frame of frames) {
|
|
let sourceSignal: AbortSignal | undefined
|
|
const unregister = ctx.typertGateway.registerRemoteEvents((signal) => {
|
|
sourceSignal = signal
|
|
return (async function* () {
|
|
yield frame as unknown as TypertRemoteEventDispatch
|
|
})()
|
|
}, REMOTE_HOST)
|
|
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
|
const reason: unknown = sourceSignal?.reason
|
|
if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error')
|
|
expect(reason.message).toContain('arguments are not lossless JSON data')
|
|
await unregister()
|
|
}
|
|
})
|
|
|
|
it('retries a colliding Remote event id before publishing the second waterfall', async () => {
|
|
const { ctx } = await setup(false)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType<typeof randomUUID>
|
|
const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType<typeof randomUUID>
|
|
randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
|
|
const firstAbort = new AbortController()
|
|
const secondAbort = new AbortController()
|
|
const first = pendingInvocation(agent, firstAbort.signal, 'first', agentId('agent-collision'))
|
|
const second = pendingInvocation(agent, secondAbort.signal, 'second', agentId('agent-collision'))
|
|
|
|
source.push(first.dispatch)
|
|
await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
|
|
source.push(second.dispatch)
|
|
await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) })
|
|
|
|
const firstReason = new Error('cancel first collision fixture')
|
|
const secondReason = new Error('cancel second collision fixture')
|
|
const firstRejected = expect(first.outcome).rejects.toBe(firstReason)
|
|
const secondRejected = expect(second.outcome).rejects.toBe(secondReason)
|
|
firstAbort.abort(firstReason)
|
|
secondAbort.abort(secondReason)
|
|
await firstRejected
|
|
await secondRejected
|
|
await unregister()
|
|
})
|
|
|
|
it('retries a colliding Remote event Client id before opening the second generation', async () => {
|
|
const { ctx } = await setup(true)
|
|
// Connection minted the operator Peer id above; only the event Client ids are counted below.
|
|
randomUuid.mockClear()
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType<typeof randomUUID>
|
|
const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType<typeof randomUUID>
|
|
randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId)
|
|
|
|
const first = await openEventClient(ctx, 'events-client-id-a')
|
|
const second = await openEventClient(ctx, 'events-client-id-b')
|
|
|
|
expect(first.clientId).toBe(firstId)
|
|
expect(second.clientId).toBe(secondId)
|
|
expect(randomUuid).toHaveBeenCalledTimes(3)
|
|
first.socket.close()
|
|
second.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('fans one scoped waterfall out and accepts the first Client result', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const first = await openEventClient(ctx, 'events-a')
|
|
const second = await openEventClient(ctx, 'events-b')
|
|
const pending = pendingInvocation(agent)
|
|
source.push(pending.dispatch)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(deliveredInvocation(first)).toBeDefined()
|
|
expect(deliveredInvocation(second)).toBeDefined()
|
|
})
|
|
const firstFrame = deliveredInvocation(first)!
|
|
const secondFrame = deliveredInvocation(second)!
|
|
expect(firstFrame.eventId).toBe(secondFrame.eventId)
|
|
expect(firstFrame).toMatchObject({
|
|
type: 'waterfall',
|
|
event: 'fixture/approval',
|
|
agentId: 'agent-1',
|
|
request: { prompt: 'ship' },
|
|
})
|
|
expect(firstFrame).not.toHaveProperty('deliveryId')
|
|
expect(secondFrame).not.toHaveProperty('deliveryId')
|
|
|
|
await sendEventResult(second, secondFrame, {
|
|
kind: 'result', value: 'allowed',
|
|
})
|
|
await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
|
|
await vi.waitFor(() => {
|
|
expect(first.frames).toContainEqual({
|
|
type: 'item',
|
|
streamId: first.streamId,
|
|
value: { type: 'cancel', eventId: firstFrame.eventId },
|
|
})
|
|
})
|
|
|
|
await sendEventResult(first, firstFrame, {
|
|
kind: 'result', value: 'rejected',
|
|
})
|
|
expect(pending.resolve).toHaveBeenCalledTimes(1)
|
|
expect(pending.reject).not.toHaveBeenCalled()
|
|
first.socket.close()
|
|
second.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('rejects the Host waterfall with the first Client listener rejection', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const client = await openEventClient(ctx, 'events-rejected')
|
|
const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-rejected'))
|
|
source.push(pending.dispatch)
|
|
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
|
const frame = deliveredInvocation(client)!
|
|
const rejected = expect(pending.outcome).rejects.toMatchObject({
|
|
name: 'UserQuestionError',
|
|
message: 'the user cancelled ask_user_question',
|
|
code: 'ASK_CANCELLED',
|
|
details: { questionId: 'question-1' },
|
|
})
|
|
|
|
await sendEventResult(client, frame, {
|
|
kind: 'rejected',
|
|
error: {
|
|
name: 'UserQuestionError',
|
|
message: 'the user cancelled ask_user_question',
|
|
code: 'ASK_CANCELLED',
|
|
details: { questionId: 'question-1' },
|
|
},
|
|
})
|
|
await rejected
|
|
expect(pending.reject).toHaveBeenCalledTimes(1)
|
|
expect(pending.resolve).not.toHaveBeenCalled()
|
|
|
|
client.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('delegates to the Host only after every active Client returns next', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const first = await openEventClient(ctx, 'events-next-a')
|
|
const second = await openEventClient(ctx, 'events-next-b')
|
|
const pending = pendingInvocation(agent)
|
|
source.push(pending.dispatch)
|
|
await vi.waitFor(() => {
|
|
expect(deliveredInvocation(first)).toBeDefined()
|
|
expect(deliveredInvocation(second)).toBeDefined()
|
|
})
|
|
const firstFrame = deliveredInvocation(first)!
|
|
const secondFrame = deliveredInvocation(second)!
|
|
|
|
await sendEventResult(first, firstFrame, { kind: 'next' })
|
|
expect(pending.resolve).not.toHaveBeenCalled()
|
|
await sendEventResult(second, secondFrame, { kind: 'next' })
|
|
await expect(pending.outcome).resolves.toEqual({ kind: 'next' })
|
|
expect(pending.resolve).toHaveBeenCalledTimes(1)
|
|
expect(pending.reject).not.toHaveBeenCalled()
|
|
first.socket.close()
|
|
second.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('delivers a pending waterfall to the first Client that connects', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const pending = pendingInvocation(agent, undefined, 'before-connect', agentId('agent-late-client'))
|
|
|
|
source.push(pending.dispatch)
|
|
await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) })
|
|
|
|
const client = await openEventClient(ctx, 'events-first-client')
|
|
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
|
const frame = deliveredInvocation(client)!
|
|
expect(frame).toMatchObject({
|
|
type: 'waterfall',
|
|
event: 'fixture/approval',
|
|
agentId: 'agent-late-client',
|
|
request: { prompt: 'before-connect' },
|
|
})
|
|
|
|
await sendEventResult(client, frame, { kind: 'result', value: 'allowed' })
|
|
await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
|
|
|
|
client.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('replays a pending event id to a replacement Client generation', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const agent = ctx.extend()
|
|
const original = await openEventClient(ctx, 'events-original')
|
|
const pending = pendingInvocation(agent)
|
|
source.push(pending.dispatch)
|
|
await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() })
|
|
const originalFrame = deliveredInvocation(original)!
|
|
const closed = once(original.socket, 'close')
|
|
original.socket.close()
|
|
await closed
|
|
|
|
const replacement = await openEventClient(ctx, 'events-replacement')
|
|
await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() })
|
|
const replayed = deliveredInvocation(replacement)!
|
|
expect(replayed.eventId).toBe(originalFrame.eventId)
|
|
expect(replayed).not.toHaveProperty('deliveryId')
|
|
await sendEventResult(replacement, replayed, {
|
|
kind: 'result', value: 'allowed',
|
|
})
|
|
await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' })
|
|
|
|
replacement.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('cancels pending deliveries when the Host signal or Context ends', async () => {
|
|
const { ctx } = await setup(true)
|
|
const source = new RemoteEventSourceProbe()
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)
|
|
const signalAgent = ctx.extend()
|
|
const contextFiber = ctx.plugin(() => {})
|
|
await contextFiber
|
|
const contextAgent = contextFiber.ctx
|
|
const client = await openEventClient(ctx, 'events-cancel')
|
|
|
|
const abort = new AbortController()
|
|
const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal', agentId('agent-signal'))
|
|
source.push(signalPending.dispatch)
|
|
await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() })
|
|
const signalFrame = deliveredInvocation(client)!
|
|
expect(signalFrame).toMatchObject({
|
|
type: 'waterfall',
|
|
agentId: 'agent-signal',
|
|
request: { prompt: 'signal' },
|
|
})
|
|
const signalReason = new Error('Host caller cancelled')
|
|
const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason)
|
|
abort.abort(signalReason)
|
|
await signalOutcome
|
|
await vi.waitFor(() => {
|
|
expect(client.frames).toContainEqual({
|
|
type: 'item',
|
|
streamId: client.streamId,
|
|
value: { type: 'cancel', eventId: signalFrame.eventId },
|
|
})
|
|
})
|
|
|
|
const contextPending = pendingInvocation(contextAgent, undefined, 'context', agentId('agent-context'))
|
|
source.push(contextPending.dispatch)
|
|
let contextFrame: RemoteEventInvocationFrame | undefined
|
|
await vi.waitFor(() => {
|
|
contextFrame = client.frames
|
|
.filter(frame => frame.type === 'item' && frame.streamId === client.streamId)
|
|
.map(frame => frame.value)
|
|
.find(value => typeof value === 'object'
|
|
&& value !== null
|
|
&& Reflect.get(value, 'event') === 'fixture/approval'
|
|
&& Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined
|
|
expect(contextFrame).toBeDefined()
|
|
})
|
|
const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Agent Context was released')
|
|
await contextFiber.dispose()
|
|
await contextOutcome
|
|
await vi.waitFor(() => {
|
|
expect(client.frames).toContainEqual({
|
|
type: 'item',
|
|
streamId: client.streamId,
|
|
value: { type: 'cancel', eventId: contextFrame!.eventId },
|
|
})
|
|
})
|
|
|
|
client.socket.close()
|
|
await unregister()
|
|
})
|
|
|
|
it('validates the internal Remote event request and reports an absent source', async () => {
|
|
const { ctx } = await setup(true)
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
|
headers: { cookie: browserCookie(ctx) },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
|
|
sendOpen(socket, 'missing', '$events', {})
|
|
await vi.waitFor(() => {
|
|
expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error')
|
|
expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable')
|
|
})
|
|
|
|
let sourceCalls = 0
|
|
const unregister = ctx.typertGateway.registerRemoteEvents(() => {
|
|
sourceCalls += 1
|
|
return (async function *(): AsyncIterable<never> {})()
|
|
}, REMOTE_HOST)
|
|
const invalidPayloads: readonly unknown[] = [
|
|
null,
|
|
[],
|
|
{},
|
|
{ other: {} },
|
|
{ args: null },
|
|
{ args: [] },
|
|
{ args: { extra: true } },
|
|
]
|
|
invalidPayloads.forEach((payload, index) => {
|
|
socket.send(JSON.stringify({
|
|
type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload,
|
|
}))
|
|
})
|
|
await vi.waitFor(() => {
|
|
expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length)
|
|
})
|
|
for (const [index] of invalidPayloads.entries()) {
|
|
const streamId = `invalid-${String(index)}`
|
|
expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error')
|
|
expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object')
|
|
}
|
|
expect(sourceCalls).toBe(1)
|
|
|
|
await unregister()
|
|
socket.close()
|
|
})
|
|
|
|
it('applies Connection trusted-host policy before accepting the Gateway socket', async () => {
|
|
const { ctx } = await setup(true)
|
|
const socket = new WebSocket(
|
|
`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`,
|
|
{ headers: { host: 'untrusted.example' } },
|
|
)
|
|
socket.on('error', () => {})
|
|
const responseEvent: unknown[] = await once(socket, 'unexpected-response')
|
|
const request = responseEvent[0]
|
|
const response = responseEvent[1]
|
|
const rejected = response as { statusCode?: number; resume(): void }
|
|
expect(rejected.statusCode).toBe(403)
|
|
rejected.resume()
|
|
;(request as { abort(): void }).abort()
|
|
})
|
|
|
|
it('answers an unauthenticated trusted Host with 401 before opening a stream', async () => {
|
|
const { ctx } = await setup(true)
|
|
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`)
|
|
socket.on('error', () => {})
|
|
const responseEvent: unknown[] = await once(socket, 'unexpected-response')
|
|
const request = responseEvent[0]
|
|
const response = responseEvent[1]
|
|
const rejected = response as { statusCode?: number; resume(): void }
|
|
expect(rejected.statusCode).toBe(401)
|
|
rejected.resume()
|
|
;(request as { abort(): void }).abort()
|
|
})
|
|
})
|
|
|
|
async function setup(
|
|
transport: boolean,
|
|
gatewayConfig: GatewayConfig = {},
|
|
): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
|
|
const ctx = new Context()
|
|
roots.push(ctx)
|
|
if (transport) {
|
|
await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
|
|
provideBrowserCredentials(ctx)
|
|
}
|
|
await ctx.plugin(TypertRegistry)
|
|
await ctx.plugin(TypertGatewayService, gatewayConfig)
|
|
if (transport) {
|
|
await ctx.plugin({ inject: [...connectionInject], apply: applyConnection })
|
|
}
|
|
await ctx.plugin(FeedService)
|
|
ctx.typert.register({
|
|
package: '@fixture/feed',
|
|
face: 'host',
|
|
schemas: [],
|
|
model: { services: [], events: [], objects: [] },
|
|
invocations: descriptors(),
|
|
})
|
|
const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService }
|
|
return { ctx, service: receiver[symbols.original] ?? receiver }
|
|
}
|
|
|
|
function descriptors(): InvocationDescriptor[] {
|
|
const label = {
|
|
name: 'label',
|
|
wire: 'label',
|
|
source: 'json' as const,
|
|
codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() },
|
|
}
|
|
const prefix = { ...label, name: 'prefix', wire: 'prefix' }
|
|
const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({
|
|
id: `@fixture/feed#feed/${method}`,
|
|
service: 'feed',
|
|
namespace: 'feed',
|
|
method,
|
|
mode: 'stream',
|
|
invocation: { kind: 'direct' },
|
|
parameters,
|
|
result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema },
|
|
})
|
|
const withUplink = (method: string, parameters: InvocationDescriptor['parameters'], cancellable = true): InvocationDescriptor => ({
|
|
...stream(method, parameters, z.string()),
|
|
uplink: { codec: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() } },
|
|
...(cancellable ? { cancellation: { parameter: 'signal' } } : {}),
|
|
})
|
|
return [
|
|
withUplink('echo', [prefix]),
|
|
withUplink('swallow', []),
|
|
withUplink('ignore', [prefix], false),
|
|
withUplink('hold', []),
|
|
withUplink('peek', []),
|
|
withUplink('drain', [], false),
|
|
withUplink('rereads', [], false),
|
|
stream('context', [label], z.string()),
|
|
{ ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } },
|
|
stream('sync', [label], z.string()),
|
|
stream('invalid', [], z.string()),
|
|
stream('nonJson', [], z.unknown()),
|
|
stream('missing', [], z.string()),
|
|
{ ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } },
|
|
stream('reject', [], z.string()),
|
|
stream('rejectWithNonJsonDetails', [], z.string()),
|
|
{
|
|
id: '@fixture/feed#feed/unary',
|
|
service: 'feed',
|
|
namespace: 'feed',
|
|
method: 'unary',
|
|
invocation: { kind: 'direct' },
|
|
parameters: [label],
|
|
result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() },
|
|
},
|
|
{
|
|
id: '@fixture/feed#feed/unaryUplink',
|
|
service: 'feed',
|
|
namespace: 'feed',
|
|
method: 'unaryUplink',
|
|
invocation: { kind: 'direct' },
|
|
parameters: [],
|
|
result: { mode: 'strict', typeSymbol: '@fixture/feed#Items', create: () => z.array(z.string()) },
|
|
},
|
|
]
|
|
}
|
|
|
|
interface RemoteEventTestClient {
|
|
readonly socket: WebSocket
|
|
readonly frames: Record<string, unknown>[]
|
|
readonly streamId: string
|
|
readonly clientId: RemoteEventClientId
|
|
readonly origin: string
|
|
readonly cookie: string
|
|
}
|
|
|
|
async function openEventClient(ctx: Context, streamId: string): Promise<RemoteEventTestClient> {
|
|
const origin = `http://127.0.0.1:${String(ctx.webServer.port)}`
|
|
const cookie = browserCookie(ctx)
|
|
const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, {
|
|
headers: { cookie },
|
|
})
|
|
await once(socket, 'open')
|
|
const frames: Record<string, unknown>[] = []
|
|
socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record<string, unknown>) })
|
|
sendOpen(socket, streamId, '$events', {})
|
|
let clientId: RemoteEventClientId | undefined
|
|
await vi.waitFor(() => {
|
|
const ready = frames.find(frame => frame.type === 'item'
|
|
&& frame.streamId === streamId
|
|
&& typeof frame.value === 'object'
|
|
&& frame.value !== null
|
|
&& Reflect.get(frame.value, 'type') === 'ready')
|
|
const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId')
|
|
expect(typeof candidate).toBe('string')
|
|
if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId
|
|
})
|
|
if (clientId === undefined) throw new Error('Remote event stream omitted its Client id')
|
|
return { socket, frames, streamId, clientId, origin, cookie }
|
|
}
|
|
|
|
function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined {
|
|
for (const frame of client.frames) {
|
|
if (frame.type !== 'item' || frame.streamId !== client.streamId) continue
|
|
const value = frame.value
|
|
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue
|
|
return value as RemoteEventInvocationFrame
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function sendEventResult(
|
|
client: RemoteEventTestClient,
|
|
frame: RemoteEventInvocationFrame,
|
|
outcome:
|
|
| { readonly kind: 'next' }
|
|
| { readonly kind: 'result'; readonly value?: unknown }
|
|
| {
|
|
readonly kind: 'rejected'
|
|
readonly error: {
|
|
readonly name: string
|
|
readonly message: string
|
|
readonly code?: string
|
|
readonly details?: unknown
|
|
}
|
|
},
|
|
): Promise<void> {
|
|
const rpcId = `remote-event-result-${client.streamId}`
|
|
const response = await fetch(`${client.origin}/api/$events/result`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', cookie: client.cookie },
|
|
body: JSON.stringify({
|
|
type: 'client-request',
|
|
rpcId,
|
|
method: '$events/result',
|
|
payload: {
|
|
args: { clientId: client.clientId, eventId: frame.eventId, outcome },
|
|
},
|
|
}),
|
|
})
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } }
|
|
if (body.result?.ok !== true) {
|
|
throw new Error(body.result?.error?.message ?? 'Remote event result failed')
|
|
}
|
|
}
|
|
|
|
function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void {
|
|
socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }))
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
function streamErrorMessage(frames: readonly Record<string, unknown>[], streamId: string): string | undefined {
|
|
const error = frames.find(frame => frame.streamId === streamId)?.error
|
|
if (typeof error !== 'object' || error === null) return undefined
|
|
const message: unknown = Reflect.get(error, 'message')
|
|
return typeof message === 'string' ? message : undefined
|
|
}
|
|
|
|
async function collect(source: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
const values: unknown[] = []
|
|
for await (const value of source) values.push(value)
|
|
return values
|
|
}
|
|
|
|
async function *toAsync<T>(values: readonly T[]): AsyncIterable<T> {
|
|
for (const value of values) yield value
|
|
}
|
|
|
|
function neverYielding(): AsyncIterable<string> {
|
|
return { [Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<string>>(() => {}) }) }
|
|
}
|
|
|
|
function abortOf(signal: AbortSignal): Promise<void> {
|
|
return new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
}
|