mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(connection): own RPC transport contracts
This commit is contained in:
@@ -119,19 +119,19 @@ async function stopWeb(running: RunningWeb): Promise<void> {
|
||||
clearTimeout(forced)
|
||||
}
|
||||
|
||||
/** POST one real API Proxy envelope while controlling the wire Host header. */
|
||||
function describeHost(port: number, host: string, cookie?: string): Promise<HttpResult> {
|
||||
/** POST one real Remote envelope while controlling the wire Host header. */
|
||||
function describeSettings(port: number, host: string, cookie?: string): Promise<HttpResult> {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'web-auth-real-cli',
|
||||
method: 'host.describe',
|
||||
payload: {},
|
||||
method: 'settings/describe',
|
||||
payload: { args: {} },
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/host.describe',
|
||||
path: '/api/settings/describe',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
host,
|
||||
@@ -165,7 +165,7 @@ describe('dsh web authentication through the real CLI', () => {
|
||||
expect(firstUrl.pathname).toBe('/')
|
||||
expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u)
|
||||
|
||||
expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({
|
||||
expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({
|
||||
status: 401,
|
||||
body: 'unauthorized',
|
||||
})
|
||||
@@ -180,13 +180,13 @@ describe('dsh web authentication through the real CLI', () => {
|
||||
expect(setCookie).not.toContain('Secure')
|
||||
const cookie = setCookie.split(';', 1)[0]!
|
||||
|
||||
const authenticated = await describeHost(port, firstUrl.host, cookie)
|
||||
const authenticated = await describeSettings(port, firstUrl.host, cookie)
|
||||
expect(authenticated.status).toBe(200)
|
||||
const authenticatedBody = JSON.parse(authenticated.body) as unknown
|
||||
expect(authenticatedBody).toMatchObject({
|
||||
type: 'server-response',
|
||||
rpcId: 'web-auth-real-cli',
|
||||
result: { ok: true, value: { version: expect.any(String) as unknown } },
|
||||
result: { ok: true, value: { namespaces: expect.any(Array) as unknown } },
|
||||
})
|
||||
|
||||
await stopWeb(first)
|
||||
@@ -194,7 +194,7 @@ describe('dsh web authentication through the real CLI', () => {
|
||||
second = await startWeb(root, dshHome, port)
|
||||
const secondUrl = new URL(second.launchUrl)
|
||||
expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token'))
|
||||
expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200)
|
||||
expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200)
|
||||
|
||||
const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777
|
||||
expect(credentialMode).toBe(0o600)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Shared scaffolding for the assembled-jsdom snapshots: the real built
|
||||
// workspace `lib/client.js` artifacts booted through AppWebEntry's
|
||||
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
|
||||
// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC
|
||||
// transport. Every file that mounts this graph needs the same boot entry list,
|
||||
// the same bundle map, the same jsdom globals, and the same mount call, and
|
||||
// differs only in what it asserts afterwards, so the scaffolding lives here.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// reach a surface only the built bundles expose; this one asserts that the
|
||||
// graph assembles at all — staged activation across the immediately tier and
|
||||
// the inject layers, per-plugin CSS injection, and a rendered journey reaching
|
||||
// chat content from the keyless FixtureApiClient transport.
|
||||
// chat content from the keyless fixture Connection RPC.
|
||||
//
|
||||
// Component behavior remains owned by per-package suites (SlotTestRuntime
|
||||
// benches over src). This smoke additionally pins the resident interaction
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// The command image-attachment envelope over the BUILT client graph (real
|
||||
// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter
|
||||
// bundles via AppWebEntry, keyless fixture Connection RPC): an enter
|
||||
// submission carrying composer images resolves only through a command whose
|
||||
// descriptor declares `input.images`. A non-declaring command refuses with
|
||||
// one composer error banner and everything retained; a declaring command
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Keyless assembled-browser coverage for the goal bar over the shipped Web
|
||||
// bundles and FixtureApiClient wire. The command creates a real projected
|
||||
// bundles and the fixture Connection RPC. The command creates a real projected
|
||||
// goal in the fixture session; the golden pins the active strip, while the
|
||||
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
|
||||
// nor a duplicate-mutation error.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC).
|
||||
// Opens the fixture history session whose turn 73 carries an image in BOTH a
|
||||
// user message and an assistant message, and pins the product surfaces: the
|
||||
// history ImageGallery loading real fixture bytes through the authorized
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the
|
||||
// keyless FixtureApiClient transport, opens the fixture session, and pins the
|
||||
// keyless fixture Connection RPC, opens the fixture session, and pins the
|
||||
// surface its max-tokens turn (72) reaches — the turn-end notice row that a
|
||||
// provider output-cap truncation must render instead of ending silently.
|
||||
//
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
|
||||
// Interaction over the REAL wire-delivered transcript (the fixture-client
|
||||
// tier pins the same gesture against FixtureApiClient; this one runs on
|
||||
// tier pins the same gesture against the fixture Connection RPC; this one runs on
|
||||
// follow-stream-fed state). Runs after the golden capture so the committed
|
||||
// aria surface stays the untouched settled state.
|
||||
await expandTurnProcesses(page)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled search-card snapshot: boots the real built workspace client bundles
|
||||
// through AppWebEntry's ModuleLoader path against the keyless
|
||||
// FixtureApiClient transport (no API key, no model round), opens the fixture
|
||||
// fixture Connection RPC (no API key, no model round), opens the fixture
|
||||
// session, and pins the search card the `grep` turn (fixture turn 67) renders in
|
||||
// the assembled application. The built-boot smoke proves the graph boots but
|
||||
// intentionally carries no behavior assertions; this is the assembled-output check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// Local submission echo over the BUILT client graph (keyless FixtureApiClient
|
||||
// Local submission echo over the BUILT client graph (keyless fixture Connection RPC
|
||||
// transport): a text-plus-image send paints its echo bubble synchronously on
|
||||
// the submit keystroke — before serialization, transport, or the fixture's
|
||||
// durable admission — with the composer already cleared and editable, and the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled todo snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the
|
||||
// keyless FixtureApiClient transport, opens the fixture session, and pins the
|
||||
// keyless fixture Connection RPC, opens the fixture session, and pins the
|
||||
// two surfaces the fixture's parallel plan (turn 74, two items `in_progress`)
|
||||
// reaches — the `todo_write` tool row and the dock's plan strip.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC).
|
||||
// Opens the fixture history session whose turn 73 carries an image in BOTH a
|
||||
// user message and an assistant message, and pins the Trajectory surfaces:
|
||||
// selecting the ledger record renders the shared ui-attachment gallery from
|
||||
|
||||
@@ -1809,7 +1809,7 @@ describe('Client Typert API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes the Fixture Host description after Remote events report ready', async () => {
|
||||
it('publishes the Fixture Host facts after Remote events report ready', async () => {
|
||||
const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
@@ -1824,7 +1824,6 @@ describe('Client Typert API', () => {
|
||||
if (connection === undefined) throw new Error('fixture Connection service is unavailable')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture')
|
||||
expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture')
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -54,8 +54,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
* the carrier's runtime values stay behind their own module edge.
|
||||
*/
|
||||
export type {
|
||||
ConnectionHandle, ConnectionSinks, ContentBlock, IApiClient,
|
||||
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
|
||||
ConnectionHandle, ConnectionSinks, ContentBlock,
|
||||
MessageId,
|
||||
RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API",
|
||||
"description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
|
||||
"version": "0.1.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -38,7 +38,8 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
@@ -51,7 +52,7 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -65,7 +66,7 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
// Central contract re-export point: every legacy API contract import inside
|
||||
// the Connection package goes through this browser-safe file.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client boundary.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
/** Browser-safe Connection protocol and shared application value exports. */
|
||||
|
||||
export type {
|
||||
ApiProxy, HostApi,
|
||||
ResponseValue,
|
||||
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, RpcMessage,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError lives in the apiproxy api layer (beside RpcResult, its
|
||||
// subject); re-exported here so connection consumers keep one contract
|
||||
// entry point.
|
||||
export {
|
||||
RpcId,
|
||||
transportError,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
ClientRequest,
|
||||
RpcError,
|
||||
RpcErrorCode,
|
||||
RpcMessage,
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcResult,
|
||||
ServerResponse,
|
||||
} from '../rpc.ts'
|
||||
export { RpcId, transportError } from '../rpc.ts'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
/** Successful value returned by the connection-generation host handshake. */
|
||||
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcResponse, RpcResult } from '../rpc.ts'
|
||||
|
||||
/**
|
||||
* Unwrap a unary response: RpcResponse<T> -> RpcResult<T> (business code only
|
||||
* cares about the result slot).
|
||||
* @param response - the unary response.
|
||||
* @returns its result slot.
|
||||
* Return the business result carried by a narrow fixture response.
|
||||
* @param response - fixture response to unwrap.
|
||||
* @returns the response's business result.
|
||||
*/
|
||||
export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
|
||||
return response.result
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { HostDescription, IApiClient } from './api.ts'
|
||||
|
||||
/** Stable Host facts delivered by one established Remote event generation. */
|
||||
export interface ConnectionHostInfo {
|
||||
/** Host account home used only to abbreviate displayed filesystem paths. */
|
||||
@@ -52,8 +50,8 @@ export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
/** Connection-generation callbacks owned by API Gateway. */
|
||||
export interface ConnectionSinks {
|
||||
/** After the generation source is ready and host.describe succeeds, first connect included. */
|
||||
onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void
|
||||
/** After the generation source reports ready, first connect included. */
|
||||
onConnected?: (host: ConnectionHostInfo) => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
|
||||
onStateChange?: (state: ConnectionState) => void
|
||||
@@ -86,7 +84,6 @@ export class ConnectionController {
|
||||
private readonly config: Required<ConnectionConfig>
|
||||
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly source: ConnectionGenerationSource,
|
||||
private readonly sinks: ConnectionSinks = {},
|
||||
config: ConnectionConfig = {},
|
||||
@@ -173,27 +170,16 @@ export class ConnectionController {
|
||||
})
|
||||
|
||||
try {
|
||||
// The source reports ready only after its incremental listeners exist;
|
||||
// describe may complete in parallel, but consumers see neither result
|
||||
// until both sides of the baseline-plus-increment handshake are ready.
|
||||
const [description, host] = await Promise.race([
|
||||
Promise.all([
|
||||
this.api.host.describe({}, ac.signal),
|
||||
const host = await Promise.race([
|
||||
waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal),
|
||||
]),
|
||||
sourceLost,
|
||||
])
|
||||
const descriptionResult = description.result
|
||||
if (!descriptionResult.ok) {
|
||||
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.emitState('connected')
|
||||
// A state sink may synchronously stop this controller. Do not publish
|
||||
// a description for a generation that no longer exists afterward.
|
||||
// A state sink may synchronously stop this controller.
|
||||
if (this.isGenerationActive(ac)) {
|
||||
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) })
|
||||
this.callSink(() => { this.sinks.onConnected?.(host) })
|
||||
}
|
||||
} catch {
|
||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||
|
||||
@@ -33,12 +33,7 @@ import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
||||
import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest,
|
||||
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId } from './api.ts'
|
||||
import type { RpcResult } from './api.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
import type {
|
||||
ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
|
||||
@@ -46,6 +41,26 @@ import type {
|
||||
|
||||
const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20
|
||||
|
||||
interface ModelSelection {
|
||||
readonly provider: string
|
||||
readonly model: string
|
||||
readonly reasoningEffort?: string
|
||||
}
|
||||
|
||||
interface ModelProviderGroup {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly models: readonly {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly reasoning?: {
|
||||
readonly efforts: readonly { readonly id: string; readonly name: string; readonly description?: string }[]
|
||||
readonly defaultEffort?: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */
|
||||
function isFixtureTokenDelta(chunk: StreamChunk): boolean {
|
||||
switch (chunk.type) {
|
||||
@@ -324,11 +339,6 @@ interface FixtureWorkspace {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(randomUuid()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: t }]
|
||||
}
|
||||
@@ -1731,34 +1741,22 @@ class FxInbox<Value> implements StreamConn<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
|
||||
* @param options - fixture branches for empty state and failure timing.
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
/** Both fixture faces over one state graph. */
|
||||
/** Fixture RPC face over one in-memory state graph. */
|
||||
export interface FixtureWorld {
|
||||
/** Legacy unary/stream API the fixture still answers. */
|
||||
readonly api: ApiProxy
|
||||
/** Generic Remote caller for the endpoints business services own. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/**
|
||||
* Build both fixture faces so a caller can drive the Remote endpoints and the
|
||||
* legacy API against one in-memory state graph.
|
||||
* Build the fixture RPC face over one in-memory state graph.
|
||||
* @param options - fixture branches for empty state and failure timing.
|
||||
* @returns the legacy API face and the Remote RPC face.
|
||||
* @returns the Remote RPC face.
|
||||
*/
|
||||
export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld {
|
||||
return createFixtureWorld(options)
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
/** Build the fixture's Remote RPC face over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: FixtureSessionSummary[] = options.empty ? [] : [
|
||||
@@ -1890,7 +1888,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 75]])
|
||||
let nextSession = 1
|
||||
let attachedSessions = options.empty ? 0 : 1
|
||||
// Workspace entities mirroring the host registry: the fixture sessions all
|
||||
// live under one workspace, whose account carries them in attach order.
|
||||
const wid = (raw: string): WorkspaceId => raw as WorkspaceId
|
||||
@@ -2015,10 +2012,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
for (const conn of followConns.get(sessionId) ?? []) conn.push(entry)
|
||||
}
|
||||
|
||||
/** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */
|
||||
function ok<P, T>(request: RpcRequest<P>, value: T): Promise<RpcResponse<T>> {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
|
||||
}
|
||||
function sessionOk<T>(value: T): Promise<ConnectionRpcResult<T>> {
|
||||
return Promise.resolve({ ok: true, value })
|
||||
}
|
||||
@@ -2817,7 +2810,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
}
|
||||
sessions.push(created)
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
emitRemote('api-session/added', [created])
|
||||
}
|
||||
@@ -3399,20 +3391,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
},
|
||||
}
|
||||
|
||||
const api: ApiProxy = {
|
||||
host: {
|
||||
describe: request => ok(request, {
|
||||
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
|
||||
}),
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// hands GET /api/session.export to the native download manager, so this
|
||||
// stub is never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call(channel, endpoint, payload, signal) {
|
||||
if (channel !== '/api') {
|
||||
@@ -3486,6 +3464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string))
|
||||
case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string))
|
||||
case 'settings/describe': return Promise.resolve(settingsRemotes.describe())
|
||||
case 'settings/canOpenAgentPresetDirectory': return Promise.resolve({ ok: true, value: true })
|
||||
case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument())
|
||||
case 'settings/openAgentPresetDirectory': return Promise.resolve(
|
||||
settingsRemotes.openAgentPresetDirectory(args.agentPreset as string),
|
||||
@@ -3504,6 +3483,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
case 'session/openWorkspacePath': {
|
||||
return sessionOk({ opened: true as const })
|
||||
}
|
||||
case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true })
|
||||
case 'session/modelCatalog': return Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -3610,58 +3590,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
}
|
||||
},
|
||||
}
|
||||
return { api, rpc }
|
||||
return { rpc }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
|
||||
* overrides the legacy protocol-level call virtual to dispatch
|
||||
* straight into the in-memory ApiProxy while still minting rpcIds, fabricating
|
||||
* the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture
|
||||
* moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
* Build the browser fixture transport from the current page's query switches.
|
||||
* @returns an in-memory Connection RPC transport.
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
const world = createFixtureWorld(fixtureOptionsFromLocation())
|
||||
this.api = world.api
|
||||
this.rpc = world.rpc
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
|
||||
}
|
||||
|
||||
protected override async callUnary<K extends keyof RpcMethodMap>(
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
void signal
|
||||
const request = rpcRequest(payload)
|
||||
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
|
||||
this.onEnvelope(full)
|
||||
const response = await this.dispatch(
|
||||
method,
|
||||
request as RpcRequest<never>,
|
||||
) as RpcResponse<ResponseValue<K>>
|
||||
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
|
||||
this.onEnvelope(fullResponse)
|
||||
return response
|
||||
}
|
||||
|
||||
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
|
||||
private dispatch(
|
||||
_method: keyof RpcMethodMap,
|
||||
request: RpcRequest<never>,
|
||||
): Promise<RpcResponse<unknown>> {
|
||||
return this.api.host.describe(request)
|
||||
}
|
||||
|
||||
export function createFixtureConnectionRpc(): ClientConnectionRpc {
|
||||
return createFixtureWorld(fixtureOptionsFromLocation()).rpc
|
||||
}
|
||||
|
||||
/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* the shared API client, and lets API Gateway own the connection loop.
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { HostDescription, IApiClient } from './api.ts'
|
||||
import {
|
||||
ConnectionController,
|
||||
type ConnectionConfig,
|
||||
@@ -11,8 +10,7 @@ import {
|
||||
type ConnectionGenerationSource,
|
||||
type ConnectionSinks,
|
||||
} from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createFixtureConnectionRpc } from './fixture.ts'
|
||||
import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
@@ -28,18 +26,15 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
// ---- Browser-safe protocol and shared value re-exports ----
|
||||
export type {
|
||||
ApiProxy, HostApi,
|
||||
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelSelection,
|
||||
MessageId,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, RpcMessage,
|
||||
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
} from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
AbstractApiClient,
|
||||
transportError,
|
||||
} from './api.ts'
|
||||
|
||||
@@ -58,14 +53,6 @@ export type {
|
||||
} from '../rpc.ts'
|
||||
export type { RpcFetch } from './rpc.ts'
|
||||
|
||||
/** Observable Host description published by each completed connection handshake. */
|
||||
export interface HostDescriptionSource {
|
||||
/** Latest connected-generation description; absent before connect and while reconnecting. */
|
||||
getSnapshot(): HostDescription | undefined
|
||||
/** Subscribe to description replacement and connection loss. */
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
/** Observable identity and Host facts for the active connection generation. */
|
||||
export interface ConnectionGenerationState {
|
||||
/** Active generation, or undefined before readiness and while reconnecting. */
|
||||
@@ -84,8 +71,6 @@ export const inject: string[] = []
|
||||
* provides both halves here instead of forking this plugin.
|
||||
*/
|
||||
export interface ClientTransportHooks {
|
||||
/** Build the API carrier: unary calls plus the two downstream event streams. */
|
||||
createApiClient(): IApiClient
|
||||
/** Transport for generic unary RPC channels (the Typert gateway). */
|
||||
fetch: RpcFetch
|
||||
/** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */
|
||||
@@ -118,16 +103,12 @@ interface ClientTransportGlobal {
|
||||
* Connection stays independent of downstream domain state.
|
||||
*/
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/**
|
||||
* Whether the privileged surface is reachable: the page authority is
|
||||
* loopback, the transport declares the page owns the Host
|
||||
* ({@link ClientTransportHooks.ownsHost}), or the context is not a browser.
|
||||
*/
|
||||
readonly isLoopback: boolean
|
||||
/** Generation-scoped Host facts, including the account home and native path-open capability. */
|
||||
readonly hostDescription: HostDescriptionSource
|
||||
/** Current Remote event generation and the Host facts carried by its opening frame. */
|
||||
readonly generation: ConnectionGenerationState
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
@@ -162,28 +143,14 @@ interface ConnectionOwner {
|
||||
export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
|
||||
const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
|
||||
const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
||||
const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
|
||||
let generationSource: ConnectionGenerationSource | undefined
|
||||
let owner: ConnectionOwner | undefined
|
||||
let generationId = 0
|
||||
let generation: ConnectionGeneration | undefined
|
||||
const generationListeners = new Set<() => void>()
|
||||
let description: HostDescription | undefined
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const publishDescription = (next: HostDescription | undefined): void => {
|
||||
if (Object.is(description, next)) return
|
||||
description = next
|
||||
for (const listener of [...descriptionListeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error('[connection] host-description listener threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
const publishGeneration = (next: ConnectionGeneration | undefined): void => {
|
||||
if (Object.is(generation, next)) return
|
||||
generation = next
|
||||
@@ -200,18 +167,9 @@ export function apply(ctx: Context): void {
|
||||
owner = undefined
|
||||
current.controller.stop()
|
||||
publishGeneration(undefined)
|
||||
publishDescription(undefined)
|
||||
}
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
generation: {
|
||||
getSnapshot: () => generation,
|
||||
subscribe: (listener) => {
|
||||
@@ -238,24 +196,17 @@ export function apply(ctx: Context): void {
|
||||
if (source === undefined) throw new Error('connection: no generation source is registered')
|
||||
const token = {}
|
||||
const ownsGeneration = (): boolean => owner?.token === token
|
||||
const controller = new ConnectionController(api, source, {
|
||||
const controller = new ConnectionController(source, {
|
||||
...sinks,
|
||||
onConnected: (next, host) => {
|
||||
onConnected: (host) => {
|
||||
const nextGeneration = { id: ++generationId, host }
|
||||
publishGeneration(nextGeneration)
|
||||
if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return
|
||||
publishDescription(next)
|
||||
// A description subscriber may synchronously stop the loop. In that
|
||||
// case publishDescription(undefined) has already retracted this
|
||||
// generation, so do not leak its stale connected notification to
|
||||
// the consumer sink afterward.
|
||||
if (!ownsGeneration() || !Object.is(description, next)) return
|
||||
sinks.onConnected?.(next, host)
|
||||
sinks.onConnected?.(host)
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
if (state === 'reconnecting') {
|
||||
publishGeneration(undefined)
|
||||
publishDescription(undefined)
|
||||
}
|
||||
if (!ownsGeneration()) return
|
||||
sinks.onStateChange?.(state)
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcId as RpcIdType,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
} from '../rpc.ts'
|
||||
import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/** Browser API carrier for unary HTTP calls. */
|
||||
|
||||
import { AbstractApiClient } from './api.ts'
|
||||
|
||||
/** Browser platform subclass supplying fetch for unary calls. */
|
||||
export class WebApiClient extends AbstractApiClient {
|
||||
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-attachment'
|
||||
import type {} from '@deepseek-ai/dsh-credentials'
|
||||
// Activates the webServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority } from './api-request-trust.ts'
|
||||
@@ -14,6 +13,7 @@ import { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export type {
|
||||
ConnectionFetchMethod,
|
||||
ConnectionFetchHandler,
|
||||
ConnectionFetchRoute,
|
||||
ConnectionIndexRequest,
|
||||
ConnectionIndexResponse,
|
||||
@@ -23,10 +23,22 @@ export type {
|
||||
ConnectionRequestRejection,
|
||||
ConnectionRpcResult,
|
||||
ConnectionTrustRequest,
|
||||
ClientRequest,
|
||||
HostConnectionHandle,
|
||||
HostConnectionFetch,
|
||||
HostConnectionRpc,
|
||||
RpcMessage,
|
||||
ServerResponse,
|
||||
} from './rpc.ts'
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export {
|
||||
clientRequestSchema,
|
||||
rpcErrorSchema,
|
||||
rpcIdSchema,
|
||||
rpcMessageSchema,
|
||||
rpcResultSchema,
|
||||
serverResponseSchema,
|
||||
} from './rpc-schema.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
@@ -51,7 +63,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi
|
||||
}
|
||||
}
|
||||
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
/** Services required before providing Connection. */
|
||||
export const inject = ['webServer', 'credentials']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
@@ -92,19 +104,13 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
|
||||
assertImageBodyCapacity(ctx, maxRequestBodyBytes)
|
||||
const connection = new HostConnectionService(
|
||||
ctx,
|
||||
trustedHosts,
|
||||
await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays),
|
||||
)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) return new Response('not found', { status: 404 })
|
||||
return await toFetchHandler(apiProxy).fetch(request)
|
||||
},
|
||||
})
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
@@ -119,5 +125,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route')
|
||||
ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) })
|
||||
ctx.inject(['attachments'], (attachmentCtx) => {
|
||||
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export const inject = ['invariants']
|
||||
* No runtime invariant: browser-session verification reads the credential
|
||||
* record asynchronously at the request that authorizes work, while the
|
||||
* credentials companion owns record commit-event lifetime. Stream/reconnect
|
||||
* sequencing is exercised directly by behavior specs, rpcId round-trip
|
||||
* discipline belongs to apiproxy, and route register/dispose symmetry is
|
||||
* sequencing and rpcId round-trip discipline are exercised directly by
|
||||
* behavior specs, and route register/dispose symmetry is
|
||||
* audited by the webserver companion.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcErrorDetailsMap,
|
||||
type RpcId as RpcIdType,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
} from './rpc.ts'
|
||||
import { clientRequestSchema } from './rpc-schema.ts'
|
||||
import { bridge, type FetchHandler } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
@@ -18,8 +16,10 @@ import type {
|
||||
ConnectionIndexRequest,
|
||||
ConnectionIndexResponse,
|
||||
ConnectionFetchRoute,
|
||||
ConnectionFetchHandler,
|
||||
HostConnectionFetch,
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcFailure,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcResult,
|
||||
ConnectionRequestRejection,
|
||||
@@ -109,15 +109,13 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one shared-channel Fetch handler from its interceptor and fallback.
|
||||
* Compose one shared-channel Fetch handler from exact routes and its interceptor.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @param fallback - handler for endpoints not claimed by the interceptor.
|
||||
* @returns Fetch handler that selects exactly one target for each request.
|
||||
* @returns Fetch handler that selects one owner or returns 404.
|
||||
*/
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: FetchHandler,
|
||||
): FetchHandler {
|
||||
): ConnectionFetchHandler {
|
||||
return {
|
||||
fetch: (request) => {
|
||||
const pathname = new URL(request.url).pathname
|
||||
@@ -126,7 +124,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
|
||||
const endpoint = endpointFromPath(channel, pathname)
|
||||
const interceptor = this.interceptors.get(channel)
|
||||
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
|
||||
return fallback.fetch(request)
|
||||
return Promise.resolve(new Response('not found', { status: 404 }))
|
||||
}
|
||||
return interceptor.fetchHandler.fetch(request)
|
||||
},
|
||||
@@ -248,7 +246,7 @@ function rpcFetchHandler(
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
|
||||
function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response {
|
||||
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
||||
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
||||
return errorResponse(rpcId, {
|
||||
@@ -269,7 +267,7 @@ function endpointFromPath(channel: string, pathname: string): string | undefined
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
@@ -295,9 +293,4 @@ function assertFetchRoute(route: ConnectionFetchRoute): void {
|
||||
if (methods.size !== route.methods.length) {
|
||||
throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`)
|
||||
}
|
||||
for (const method of methods) {
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Runtime validation for Connection RPC envelopes. */
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from './rpc.ts'
|
||||
|
||||
/** Correlation id after wire validation. */
|
||||
export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
|
||||
|
||||
/** Generic endpoint failure carried in a response envelope. */
|
||||
export const rpcErrorSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
/**
|
||||
* Build the result parser for one endpoint value parser.
|
||||
* @param value - endpoint-owned success-value parser.
|
||||
* @returns parser for either a success value or generic failure.
|
||||
*/
|
||||
export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodType<{
|
||||
readonly ok: true
|
||||
readonly value: T
|
||||
} | {
|
||||
readonly ok: false
|
||||
readonly error: z.infer<typeof rpcErrorSchema>
|
||||
}> {
|
||||
return z.union([
|
||||
z.object({ ok: z.literal(true), value }),
|
||||
z.object({ ok: z.literal(false), error: rpcErrorSchema }),
|
||||
])
|
||||
}
|
||||
|
||||
/** Client request envelope; endpoint payload validation belongs to its owner. */
|
||||
export const clientRequestSchema = z.object({
|
||||
type: z.literal('client-request'),
|
||||
rpcId: rpcIdSchema,
|
||||
method: z.string(),
|
||||
payload: z.unknown(),
|
||||
}) as z.ZodType<ClientRequest>
|
||||
|
||||
/** Server response envelope; endpoint value validation belongs to its caller. */
|
||||
export const serverResponseSchema = z.object({
|
||||
type: z.literal('server-response'),
|
||||
rpcId: rpcIdSchema,
|
||||
result: rpcResultSchema(z.unknown().optional()),
|
||||
}) as z.ZodType<ServerResponse>
|
||||
|
||||
/** Either Connection RPC envelope direction. */
|
||||
export const rpcMessageSchema = z.discriminatedUnion('type', [
|
||||
clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
|
||||
serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
|
||||
]) as unknown as z.ZodType<RpcMessage>
|
||||
@@ -1,5 +1,20 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Correlation id minted by a caller and echoed by the Connection response. */
|
||||
export type RpcId = Branded<'rpc-id'>
|
||||
|
||||
/**
|
||||
* Brand one validated string as a Connection correlation id.
|
||||
* @param id - validated wire identity.
|
||||
* @returns the same string with the correlation-id brand.
|
||||
*/
|
||||
export function RpcId(id: string): RpcId {
|
||||
return id as RpcId
|
||||
}
|
||||
|
||||
/** Carrier-neutral failure returned by one logical RPC endpoint. */
|
||||
export interface ConnectionRpcFailure {
|
||||
readonly code: string
|
||||
@@ -12,6 +27,81 @@ export type ConnectionRpcResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: ConnectionRpcFailure }
|
||||
|
||||
/** Typed failure details used by Client Session adapters. */
|
||||
export interface RpcErrorDetailsMap {
|
||||
'bad-request': { issues: object[] }
|
||||
'cancelled': {}
|
||||
'session-not-found': { sessionId: SessionId }
|
||||
'invalid-time-zone': { value: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
/** Error codes used by Client Session adapters. */
|
||||
export type RpcErrorCode = keyof RpcErrorDetailsMap
|
||||
|
||||
/** Typed failure used by Client Session adapters. */
|
||||
export type RpcError = {
|
||||
[Code in RpcErrorCode]: {
|
||||
readonly code: Code
|
||||
readonly message: string
|
||||
readonly details: RpcErrorDetailsMap[Code]
|
||||
}
|
||||
}[RpcErrorCode]
|
||||
|
||||
/** Historical short name for a generic Connection result. */
|
||||
export type RpcResult<T> = ConnectionRpcResult<T>
|
||||
|
||||
/**
|
||||
* Convert a rejected transport operation into a generic failure result.
|
||||
* @param error - rejected transport value.
|
||||
* @returns an `internal` failure preserving the available message.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow request form used by direct fixture adapters. */
|
||||
export interface RpcRequest<P> {
|
||||
readonly rpcId: RpcId
|
||||
readonly payload: P
|
||||
}
|
||||
|
||||
/** Narrow response form used by direct fixture adapters. */
|
||||
export interface RpcResponse<T> {
|
||||
readonly rpcId: RpcId
|
||||
readonly result: RpcResult<T>
|
||||
}
|
||||
|
||||
/** Full request envelope carried by Connection RPC transports. */
|
||||
export interface ClientRequest {
|
||||
readonly type: 'client-request'
|
||||
readonly rpcId: RpcId
|
||||
readonly method: string
|
||||
readonly payload: unknown
|
||||
}
|
||||
|
||||
/** Full response envelope carried by Connection RPC transports. */
|
||||
export interface ServerResponse {
|
||||
readonly type: 'server-response'
|
||||
readonly rpcId: RpcId
|
||||
readonly result: ConnectionRpcResult<unknown>
|
||||
}
|
||||
|
||||
/** Complete Connection RPC envelope union. */
|
||||
export type RpcMessage = ClientRequest | ServerResponse
|
||||
|
||||
/** HTTP request facts consumed by browser trust and authentication. */
|
||||
export interface ConnectionTrustRequest {
|
||||
/** Request headers supplied by either the Fetch or node:http representation. */
|
||||
@@ -100,6 +190,13 @@ export interface HostConnectionHandle {
|
||||
/** Exact Fetch routes for streaming or browser-native responses. */
|
||||
readonly fetch: HostConnectionFetch
|
||||
|
||||
/**
|
||||
* Compose exact Fetch routes and the shared-channel RPC interceptor.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @returns Fetch handler for trusted, authenticated requests.
|
||||
*/
|
||||
createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler
|
||||
|
||||
/**
|
||||
* Apply Connection's Host/Origin checks and browser authentication to
|
||||
* another Web route.
|
||||
@@ -124,6 +221,16 @@ export interface HostConnectionHandle {
|
||||
authenticatedUrl(baseUrl: string): string
|
||||
}
|
||||
|
||||
/** Transport-independent Fetch handler used by HTTP and worker carriers. */
|
||||
export interface ConnectionFetchHandler {
|
||||
/**
|
||||
* Dispatch one already-authenticated request.
|
||||
* @param request - Fetch request below the shared channel.
|
||||
* @returns the registered response or a 404 response.
|
||||
*/
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
type ConnectionGenerationSource,
|
||||
type ConnectionHandle,
|
||||
} from '../src/client/index.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = {
|
||||
location?: { hostname: string; search: string; origin?: string }
|
||||
@@ -61,20 +59,22 @@ async function mount(): Promise<ConnectionHandle> {
|
||||
}
|
||||
|
||||
describe('connection client apply', () => {
|
||||
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
|
||||
it('treats a runtime without browser location as local', async () => {
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('mounts ctx.connection and identifies a loopback page', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
|
||||
it('selects the fixture RPC transport under ?fixture', async () => {
|
||||
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
|
||||
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
|
||||
delete (globalThis as Win).location
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
await expect(handle.rpc.call('/api', 'settings/describe', { args: {} }))
|
||||
.resolves.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('reports non-loopback page authority through the connection handle', async () => {
|
||||
@@ -98,10 +98,10 @@ describe('connection client apply', () => {
|
||||
|
||||
const loop = handle.start({})
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
unregisterSecond()
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
loop.stop()
|
||||
})
|
||||
|
||||
@@ -110,26 +110,26 @@ describe('connection client apply', () => {
|
||||
const handle = await mount()
|
||||
installGeneration(handle)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const descriptions: Array<boolean | undefined> = []
|
||||
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
|
||||
const generations: Array<string | undefined> = []
|
||||
const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') })
|
||||
const stopGeneration = handle.generation.subscribe(() => {
|
||||
generations.push(handle.generation.getSnapshot()?.host.home)
|
||||
})
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
let connected = 0
|
||||
const loop = handle.start({ onConnected: () => { connected++ } })
|
||||
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(descriptions).toEqual([true, undefined])
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
expect(generations).toEqual(['/h', undefined])
|
||||
expect(connected).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
stopThrowing()
|
||||
stopDescription()
|
||||
stopGeneration()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -140,87 +140,87 @@ describe('connection client apply', () => {
|
||||
|
||||
const first = handle.start({})
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
first.stop()
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
|
||||
const second = handle.start({})
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
first.stop()
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
|
||||
second.stop()
|
||||
generation.end()
|
||||
})
|
||||
|
||||
it('does not announce a generation synchronously stopped by a description subscriber', async () => {
|
||||
it('does not announce a generation synchronously stopped by a generation subscriber', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
installGeneration(handle)
|
||||
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
|
||||
let sawDescription = false
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
if (handle.hostDescription.getSnapshot() === undefined) return
|
||||
sawDescription = true
|
||||
let sawGeneration = false
|
||||
const stopGeneration = handle.generation.subscribe(() => {
|
||||
if (handle.generation.getSnapshot() === undefined) return
|
||||
sawGeneration = true
|
||||
owner.loop?.stop()
|
||||
})
|
||||
const connected = vi.fn()
|
||||
const loop = handle.start({ onConnected: connected })
|
||||
owner.loop = loop
|
||||
try {
|
||||
await vi.waitFor(() => { expect(sawDescription).toBe(true) })
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(sawGeneration).toBe(true) })
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
expect(connected).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
stopDescription()
|
||||
stopGeneration()
|
||||
loop.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('retracts the host description while reconnecting and republishes the next generation', async () => {
|
||||
it('retracts the generation while reconnecting and publishes the next generation', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const generation = installGeneration(handle)
|
||||
const descriptions: Array<boolean | undefined> = []
|
||||
const reconnectSnapshots: Array<boolean | undefined> = []
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
|
||||
const generations: Array<string | undefined> = []
|
||||
const reconnectSnapshots: Array<string | undefined> = []
|
||||
const stopGeneration = handle.generation.subscribe(() => {
|
||||
generations.push(handle.generation.getSnapshot()?.host.home)
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const loop = handle.start({
|
||||
onStateChange: (state) => {
|
||||
if (state === 'reconnecting') {
|
||||
reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath)
|
||||
reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home)
|
||||
}
|
||||
},
|
||||
}, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 })
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
generation.end()
|
||||
|
||||
await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
|
||||
await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) })
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) })
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
} finally {
|
||||
stopDescription()
|
||||
stopGeneration()
|
||||
loop.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not announce reconnecting after a description subscriber stops the loop', async () => {
|
||||
it('does not announce reconnecting after a generation subscriber stops the loop', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const generation = installGeneration(handle)
|
||||
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
|
||||
let stoppedOnRetraction = false
|
||||
const stopDescription = handle.hostDescription.subscribe(() => {
|
||||
if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return
|
||||
const stopGeneration = handle.generation.subscribe(() => {
|
||||
if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return
|
||||
stoppedOnRetraction = true
|
||||
owner.loop.stop()
|
||||
})
|
||||
@@ -232,38 +232,20 @@ describe('connection client apply', () => {
|
||||
owner.loop = loop
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
|
||||
expect(handle.generation.getSnapshot()?.host.home).toBe('/h')
|
||||
})
|
||||
generation.end()
|
||||
|
||||
await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) })
|
||||
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
|
||||
expect(handle.generation.getSnapshot()).toBeUndefined()
|
||||
expect(states).toEqual(['connected'])
|
||||
} finally {
|
||||
stopDescription()
|
||||
stopGeneration()
|
||||
loop.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('WebApiClient keeps unary calls on globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: string[] = []
|
||||
globalThis.fetch = (input: URL | RequestInfo) => {
|
||||
seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
|
||||
return Promise.resolve(new Response('{}', { status: 200 }))
|
||||
}
|
||||
try {
|
||||
// Schema rejection is fine — the transport hop is the assertion.
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
|
||||
})
|
||||
|
||||
it('carries RPC calls without requiring secure-context randomUUID', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
vi.stubGlobal('crypto', {
|
||||
@@ -311,7 +293,6 @@ describe('connection client apply', () => {
|
||||
})(),
|
||||
)
|
||||
;(globalThis as Win).__DSH_TRANSPORT__ = {
|
||||
createApiClient: () => new FixtureApiClient(),
|
||||
fetch: vi.fn<ClientTransportHooks['fetch']>(),
|
||||
openStream,
|
||||
ownsHost: true,
|
||||
@@ -428,7 +409,7 @@ describe('connection client apply', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
|
||||
it('carries Goal Remotes over the client-only fixture state', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const created = await handle.rpc.call('/api', 'goals/create', {
|
||||
|
||||
@@ -1,117 +1,52 @@
|
||||
/**
|
||||
* ConnectionController: strict readiness handshake (describe + incremental
|
||||
* source ready), generation
|
||||
* abort on loss, backoff reconnection, state transitions, and sink-exception
|
||||
* isolation. Real (short) timers — the timeout and backoff are configurable,
|
||||
* so tests run them at millisecond scale.
|
||||
*/
|
||||
/** Connection generation readiness, loss, retry, and sink isolation. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionState } from '../src/client/connection.ts'
|
||||
import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts'
|
||||
import { ConnectionController } from '../src/client/connection.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.client.ts'
|
||||
import { FakeGenerationSource } from './fake-generation.client.ts'
|
||||
|
||||
const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }
|
||||
|
||||
describe('connection lifecycle', () => {
|
||||
it('announces connected after describe plus generation readiness', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const descriptions: boolean[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
onConnected: (description) => {
|
||||
connected++
|
||||
descriptions.push(description.canOpenPath)
|
||||
},
|
||||
it('announces connected with the Host facts from generation readiness', async () => {
|
||||
const source = new FakeGenerationSource()
|
||||
const homes: string[] = []
|
||||
const controller = new ConnectionController(source.source, {
|
||||
onConnected: (host) => { homes.push(host.home) },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(descriptions).toEqual([true])
|
||||
await vi.waitFor(() => { expect(homes).toEqual(['/h']) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const source = new FakeGenerationSource()
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
|
||||
const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
api.failStreams(new Error('stream torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
|
||||
expect(api.openGenerationCount).toBe(1)
|
||||
source.fail(new Error('stream torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) })
|
||||
expect(source.activeCount).toBe(1)
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
// stop() aborts the live generation and no reconnect follows.
|
||||
await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
expect(api.openGenerationCount).toBe(0)
|
||||
})
|
||||
|
||||
it('treats describe failure as generation failure and retries', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls++
|
||||
return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
|
||||
expect(connected).toBe(0) // never announced during the failed generation
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a host.describe business error as generation failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls += 1
|
||||
if (describeCalls === 1) {
|
||||
return Promise.resolve({
|
||||
rpcId: 'bad-describe' as never,
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'internal' as const, message: 'not ready', details: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
expect(source.activeCount).toBe(0)
|
||||
})
|
||||
|
||||
it('isolates a connected sink exception from the generation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const source = new FakeGenerationSource()
|
||||
let connected = 0
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
const controller = new ConnectionController(source.source, {
|
||||
onConnected: () => {
|
||||
connected++
|
||||
throw new Error('business layer bug')
|
||||
@@ -120,7 +55,7 @@ describe('connection lifecycle', () => {
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(api.openGenerationCount).toBe(1)
|
||||
expect(source.activeCount).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
|
||||
} finally {
|
||||
controller.stop()
|
||||
@@ -128,47 +63,75 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('holds onConnected until the incremental source is ready after describe succeeds', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.holdGenerationReady = true
|
||||
it('holds onConnected until the incremental source reports ready', async () => {
|
||||
const source = new FakeGenerationSource()
|
||||
source.holdReady = true
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
|
||||
const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
expect(connected).toBe(0) // describe alone must not announce
|
||||
api.releaseGenerationReady()
|
||||
expect(connected).toBe(0)
|
||||
source.releaseReady()
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a generation whose source ends during readiness and retries', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls++
|
||||
return describeCalls === 1
|
||||
? firstDescribe.promise
|
||||
: Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
|
||||
it('accepts only the first readiness report from one generation', async () => {
|
||||
const homes: string[] = []
|
||||
const source: ConnectionGenerationSource = (signal, ready) => {
|
||||
ready({ home: '/first' })
|
||||
ready({ home: '/duplicate' })
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
const controller = new ConnectionController(source, {
|
||||
onConnected: (host) => { homes.push(host.home) },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(homes).toEqual(['/first']) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not announce readiness after a stop queued from the ready callback', async () => {
|
||||
const owner: { controller?: ConnectionController } = {}
|
||||
let sourceCalls = 0
|
||||
const connected = vi.fn()
|
||||
const source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
|
||||
sourceCalls++
|
||||
ready({ home: '/h' })
|
||||
queueMicrotask(() => { owner.controller?.stop() })
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
const controller = new ConnectionController(source, { onConnected: connected }, FAST)
|
||||
owner.controller = controller
|
||||
controller.start()
|
||||
await vi.waitFor(() => { expect(sourceCalls).toBe(1) })
|
||||
expect(connected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a generation whose source ends during readiness and retries', async () => {
|
||||
const source = new FakeGenerationSource()
|
||||
source.holdReady = true
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
const controller = new ConnectionController(source.source, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) })
|
||||
api.endStreams()
|
||||
firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
|
||||
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
|
||||
source.holdReady = false
|
||||
source.end()
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['reconnecting', 'connected'])
|
||||
} finally {
|
||||
@@ -181,22 +144,21 @@ describe('connection lifecycle', () => {
|
||||
{ label: 'ends normally', fail: () => Promise.resolve() },
|
||||
{
|
||||
label: 'rejects with a non-Error reason',
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario.
|
||||
fail: () => Promise.reject('fixture offline'),
|
||||
},
|
||||
])('retries when the generation source $label before reporting ready', async ({ fail }) => {
|
||||
const api = new FakeApiClient()
|
||||
let sourceCalls = 0
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, (signal, ready) => {
|
||||
const source: ConnectionGenerationSource = (signal, ready) => {
|
||||
sourceCalls++
|
||||
if (sourceCalls === 1) return fail()
|
||||
ready({ home: '/h' })
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}, { onConnected: () => { connected++ } }, FAST)
|
||||
}
|
||||
const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
|
||||
@@ -208,19 +170,19 @@ describe('connection lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects and retries a generation whose source never reports ready', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.suppressGenerationReady = true
|
||||
const source = new FakeGenerationSource()
|
||||
source.suppressReady = true
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(
|
||||
api,
|
||||
api.generation,
|
||||
source.source,
|
||||
{ onConnected: () => { connected++ } },
|
||||
{ ...FAST, generationReadyTimeoutMs: 20 },
|
||||
)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) })
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) })
|
||||
await new Promise(resolve => setTimeout(resolve, 45))
|
||||
expect(connected).toBe(0)
|
||||
} finally {
|
||||
controller.stop()
|
||||
@@ -229,11 +191,11 @@ describe('connection lifecycle', () => {
|
||||
})
|
||||
|
||||
it('emits deduplicated connected/reconnecting state transitions', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const source = new FakeGenerationSource()
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
const controller = new ConnectionController(source.source, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
@@ -241,7 +203,7 @@ describe('connection lifecycle', () => {
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['connected'])
|
||||
api.failStreams(new Error('torn'))
|
||||
source.fail(new Error('torn'))
|
||||
await vi.waitFor(() => { expect(connected).toBe(2) })
|
||||
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
|
||||
} finally {
|
||||
@@ -251,10 +213,10 @@ describe('connection lifecycle', () => {
|
||||
})
|
||||
|
||||
it('does not announce a generation stopped synchronously by its connected state sink', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const source = new FakeGenerationSource()
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
const controller = new ConnectionController(source.source, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: (state) => {
|
||||
states.push(state)
|
||||
@@ -264,59 +226,58 @@ describe('connection lifecycle', () => {
|
||||
|
||||
controller.start()
|
||||
await vi.waitFor(() => { expect(states).toEqual(['connected']) })
|
||||
await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBe(0) })
|
||||
expect(connected).toBe(0)
|
||||
})
|
||||
|
||||
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls++
|
||||
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
|
||||
}
|
||||
let sourceCalls = 0
|
||||
const states: ConnectionState[] = []
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, api.generation, {
|
||||
const source: ConnectionGenerationSource = (signal, ready) => {
|
||||
sourceCalls++
|
||||
if (sourceCalls <= 2) return Promise.reject(new Error('down'))
|
||||
ready({ home: '/h' })
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
const controller = new ConnectionController(source, {
|
||||
onConnected: () => { connected++ },
|
||||
onStateChange: state => states.push(state),
|
||||
}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
|
||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
|
||||
await vi.waitFor(() => { expect(sourceCalls).toBe(3) })
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
|
||||
expect(states).toEqual(['reconnecting', 'connected'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs with no sinks at all (every callback slot optional)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const controller = new ConnectionController(api, api.generation, {}, FAST)
|
||||
it('runs with no sinks at all', async () => {
|
||||
const source = new FakeGenerationSource()
|
||||
const controller = new ConnectionController(source.source, {}, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
await vi.waitFor(() => { expect(source.activeCount).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('start() is idempotent (one loop, one stream set)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
it('start() is idempotent', async () => {
|
||||
const source = new FakeGenerationSource()
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
|
||||
const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(api.openGenerationCount).toBe(1)
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(source.activeCount).toBe(1)
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). The generation source is a hand pump.
|
||||
import type { IApiClient, RpcResponse } from '../src/client/api.ts'
|
||||
import type { ConnectionGenerationSource } from '../src/client/connection.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
export interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
reject(error: unknown): void
|
||||
}
|
||||
|
||||
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let nextRpc = 0
|
||||
|
||||
export function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
|
||||
}
|
||||
|
||||
|
||||
type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
|
||||
|
||||
interface StreamConn {
|
||||
feed(item: StreamItem): void
|
||||
}
|
||||
|
||||
export class FakeApiClient implements IApiClient {
|
||||
/** Chronological call record: [method, payload]. */
|
||||
readonly calls: { method: string; payload: unknown }[] = []
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{
|
||||
version: string
|
||||
cwd: string
|
||||
attachedSessions: number
|
||||
home: string
|
||||
canOpenPath: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({
|
||||
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
|
||||
}))
|
||||
|
||||
private readonly generationConns: StreamConn[] = []
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
}
|
||||
|
||||
/** When true, the source never reports ready. */
|
||||
suppressGenerationReady = false
|
||||
|
||||
/** When true, ready callbacks remain parked until the test releases them. */
|
||||
holdGenerationReady = false
|
||||
private heldOpens: (() => void)[] = []
|
||||
|
||||
releaseGenerationReady(): void {
|
||||
const held = this.heldOpens
|
||||
this.heldOpens = []
|
||||
for (const fire of held) fire()
|
||||
}
|
||||
|
||||
readonly generation: ConnectionGenerationSource = (signal, ready) =>
|
||||
this.openGeneration(signal, ready)
|
||||
|
||||
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
|
||||
endStreams(): void {
|
||||
for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' })
|
||||
}
|
||||
|
||||
failStreams(error: unknown): void {
|
||||
for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error })
|
||||
}
|
||||
|
||||
get openGenerationCount(): number {
|
||||
return this.generationConns.length
|
||||
}
|
||||
|
||||
callsOf(method: string): unknown[] {
|
||||
return this.calls.filter(c => c.method === method).map(c => c.payload)
|
||||
}
|
||||
|
||||
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
|
||||
this.calls.push({ method, payload })
|
||||
return response
|
||||
}
|
||||
|
||||
private async openGeneration(
|
||||
signal: AbortSignal,
|
||||
onOpen: (host: { readonly home: string }) => void,
|
||||
): Promise<void> {
|
||||
const inbox: StreamItem[] = []
|
||||
let wake: (() => void) | null = null
|
||||
const conn: StreamConn = {
|
||||
feed: (item) => {
|
||||
inbox.push(item)
|
||||
wake?.()
|
||||
},
|
||||
}
|
||||
this.generationConns.push(conn)
|
||||
const ready = (): void => { onOpen({ home: '/h' }) }
|
||||
if (this.holdGenerationReady) this.heldOpens.push(ready)
|
||||
else if (!this.suppressGenerationReady) ready()
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
while (inbox.length > 0) {
|
||||
const item = inbox.shift() as StreamItem
|
||||
if (item.kind === 'end') return
|
||||
if (item.kind === 'fail') throw item.error
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
wake = null
|
||||
}
|
||||
} finally {
|
||||
this.generationConns.splice(this.generationConns.indexOf(conn), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Test-local programmable Connection generation source. */
|
||||
import type { ConnectionGenerationSource } from '../src/client/connection.ts'
|
||||
|
||||
type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
|
||||
|
||||
interface StreamConnection {
|
||||
feed(item: StreamItem): void
|
||||
}
|
||||
|
||||
/** Hand-pumped generation source for Connection lifecycle tests. */
|
||||
export class FakeGenerationSource {
|
||||
private readonly connections: StreamConnection[] = []
|
||||
|
||||
/** When true, the source never reports ready. */
|
||||
suppressReady = false
|
||||
|
||||
/** When true, ready callbacks remain parked until the test releases them. */
|
||||
holdReady = false
|
||||
|
||||
private heldReady: Array<() => void> = []
|
||||
|
||||
/** Open one generation. */
|
||||
readonly source: ConnectionGenerationSource = (signal, ready) => this.open(signal, ready)
|
||||
|
||||
/** Release every generation currently parked before readiness. */
|
||||
releaseReady(): void {
|
||||
const held = this.heldReady
|
||||
this.heldReady = []
|
||||
for (const fire of held) fire()
|
||||
}
|
||||
|
||||
/** End every active generation normally. */
|
||||
end(): void {
|
||||
for (const connection of [...this.connections]) connection.feed({ kind: 'end' })
|
||||
}
|
||||
|
||||
/** Fail every active generation. */
|
||||
fail(error: unknown): void {
|
||||
for (const connection of [...this.connections]) connection.feed({ kind: 'fail', error })
|
||||
}
|
||||
|
||||
/** Number of currently active generations. */
|
||||
get activeCount(): number {
|
||||
return this.connections.length
|
||||
}
|
||||
|
||||
private async open(
|
||||
signal: AbortSignal,
|
||||
onReady: (host: { readonly home: string }) => void,
|
||||
): Promise<void> {
|
||||
const inbox: StreamItem[] = []
|
||||
let wake: (() => void) | null = null
|
||||
const connection: StreamConnection = {
|
||||
feed: (item) => {
|
||||
inbox.push(item)
|
||||
wake?.()
|
||||
},
|
||||
}
|
||||
this.connections.push(connection)
|
||||
const ready = (): void => { onReady({ home: '/h' }) }
|
||||
if (this.holdReady) this.heldReady.push(ready)
|
||||
else if (!this.suppressReady) ready()
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
while (inbox.length > 0) {
|
||||
const item = inbox.shift() as StreamItem
|
||||
if (item.kind === 'end') return
|
||||
if (item.kind === 'fail') throw item.error
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
wake = null
|
||||
}
|
||||
} finally {
|
||||
this.connections.splice(this.connections.indexOf(connection), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,17 +19,16 @@ async function mounted(): Promise<{
|
||||
}
|
||||
|
||||
describe('Connection exact Fetch routes', () => {
|
||||
it('dispatches owned methods before the transitional fallback', async () => {
|
||||
it('dispatches owned methods and returns 404 for unclaimed requests', async () => {
|
||||
const { connection, dispose: disposeFiber } = await mounted()
|
||||
const route = vi.fn(async (request: Request) =>
|
||||
Response.json({ query: new URL(request.url).searchParams.get('sessionId') }))
|
||||
const fallback = vi.fn(async () => new Response('fallback', { status: 418 }))
|
||||
const dispose = connection.fetch.register({
|
||||
path: '/api/session.export',
|
||||
methods: ['GET', 'HEAD'],
|
||||
fetch: route,
|
||||
})
|
||||
const shared = connection.createSharedFetchHandler('/api', { fetch: fallback })
|
||||
const shared = connection.createSharedFetchHandler('/api')
|
||||
|
||||
const response = await shared.fetch(new Request(
|
||||
'http://host/api/session.export?sessionId=session-1',
|
||||
@@ -37,16 +36,12 @@ describe('Connection exact Fetch routes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ query: 'session-1' })
|
||||
expect(route).toHaveBeenCalledOnce()
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
|
||||
const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }))
|
||||
expect(post.status).toBe(418)
|
||||
expect(fallback).toHaveBeenCalledOnce()
|
||||
expect(post.status).toBe(404)
|
||||
|
||||
await dispose()
|
||||
const withdrawn = await shared.fetch(new Request('http://host/api/session.export'))
|
||||
expect(withdrawn.status).toBe(418)
|
||||
expect(fallback).toHaveBeenCalledTimes(2)
|
||||
expect(withdrawn.status).toBe(404)
|
||||
await disposeFiber()
|
||||
})
|
||||
|
||||
@@ -61,10 +56,6 @@ describe('Connection exact Fetch routes', () => {
|
||||
expect(() => connection.fetch.register({
|
||||
path: '/api/session.export', methods: ['GET', 'GET'], fetch,
|
||||
})).toThrow('repeats a method')
|
||||
expect(() => connection.fetch.register({
|
||||
path: '/api/session.export', methods: ['POST' as 'GET'], fetch,
|
||||
})).toThrow('unsupported method')
|
||||
|
||||
const dispose = connection.fetch.register({
|
||||
path: '/api/session.export', methods: ['GET'], fetch,
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('createFixtureApi commands/skills', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient command/skill dispatch', () => {
|
||||
describe('fixture Connection command/skill dispatch', () => {
|
||||
it('routes the Remote command and skill rows through one state graph', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') })
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ModelSelection,
|
||||
RpcMessage,
|
||||
RpcRequest,
|
||||
RpcResponse,
|
||||
RpcResult,
|
||||
@@ -12,7 +10,7 @@ import { RpcId } from '../src/client/api.ts'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import {
|
||||
FixtureApiClient,
|
||||
createFixtureConnectionRpc,
|
||||
createFixtureFaces,
|
||||
type FixtureOptions,
|
||||
} from '../src/client/fixture.ts'
|
||||
@@ -21,6 +19,7 @@ import type {
|
||||
} from '../src/rpc.ts'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
|
||||
import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' }
|
||||
@@ -288,7 +287,7 @@ interface FixtureRemoteEventStream extends AsyncIterable<FixtureRemoteEventFrame
|
||||
readonly clientId: Promise<string>
|
||||
}
|
||||
|
||||
type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
|
||||
type FixtureTestApi = {
|
||||
/** The directory-picking Remote namespace as the fixture serves it. */
|
||||
readonly directoryPickerRemote: {
|
||||
pick: () => Promise<ConnectionRpcResult<string | null>>
|
||||
@@ -307,8 +306,8 @@ type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
|
||||
|
||||
/** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */
|
||||
function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi {
|
||||
const { api, rpc } = createFixtureFaces(options)
|
||||
return Object.assign(api, {
|
||||
const { rpc } = createFixtureFaces(options)
|
||||
return {
|
||||
directoryPickerRemote: {
|
||||
pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as
|
||||
Promise<ConnectionRpcResult<string | null>>,
|
||||
@@ -327,7 +326,7 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi {
|
||||
remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal),
|
||||
answerRemoteEvent: (result: FixtureRemoteEventResult) =>
|
||||
rpc.call('/api', '$events/result', { args: result }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The fixture's Credentials Remote endpoints over the shared RPC carrier. */
|
||||
@@ -1103,16 +1102,6 @@ describe('createFixtureApi', () => {
|
||||
expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request'])
|
||||
})
|
||||
|
||||
it('describe answers the fixture identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' },
|
||||
})
|
||||
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.directoryPickerRemote.createDirectory('/', 'srv')
|
||||
@@ -1613,38 +1602,16 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
describe('fixture Connection RPC', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
||||
const client = new FixtureApiClient()
|
||||
// Protected at compile time only; reach it directly to pin the tripwire message.
|
||||
expect(() => (client as unknown as { doFetch(): Promise<Response> }).doFetch()).toThrow(/doFetch must be unreachable/)
|
||||
})
|
||||
|
||||
it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const tapped: RpcMessage[] = []
|
||||
client.subscribeEnvelopes(batch => tapped.push(...batch))
|
||||
const response = await client.host.describe({})
|
||||
expect(response.result.ok).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
const kinds = tapped.map(m => m.type)
|
||||
expect(kinds).toContain('client-request')
|
||||
expect(kinds).toContain('server-response')
|
||||
})
|
||||
const request = tapped.find(m => m.type === 'client-request')
|
||||
const reply = tapped.find(m => m.type === 'server-response')
|
||||
expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier
|
||||
})
|
||||
|
||||
it('covers the whole unary dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const sessions = createSessionClient(client.rpc)
|
||||
const workspaces = createWorkspaceClient(client.rpc)
|
||||
it('covers the migrated Remote dispatch table', async () => {
|
||||
const rpc = createFixtureConnectionRpc()
|
||||
const sessions = createSessionClient(rpc)
|
||||
const workspaces = createWorkspaceClient(rpc)
|
||||
expect((await sessions.search(
|
||||
{ query: 'fixture' },
|
||||
new AbortController().signal,
|
||||
@@ -1655,8 +1622,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
expect((await sessions.history({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0)
|
||||
expect((await readWorkspaceBaseline(createWorkspaceRemote(rpc))).items).not.toHaveLength(0)
|
||||
const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' })
|
||||
if (!workspace.result.ok) throw new Error('workspace create failed')
|
||||
expect(workspace.result.value.workspace.title).toBe('via-client')
|
||||
@@ -1672,13 +1638,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
})
|
||||
|
||||
it('folds the goal lifecycle over the Goal Remotes', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const sessions = createSessionClient(client.rpc)
|
||||
const rpc = createFixtureConnectionRpc()
|
||||
const sessions = createSessionClient(rpc)
|
||||
const created = await sessions.create({})
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
const goal = (endpoint: string, args: Record<string, unknown>) =>
|
||||
client.rpc.call('/api', endpoint, { args: { agentId: id, ...args } })
|
||||
rpc.call('/api', endpoint, { args: { agentId: id, ...args } })
|
||||
|
||||
// create → edit → pause → resume → complete → clear; each mutation advances the CAS
|
||||
// revision by one (state rides the projection frames).
|
||||
@@ -1717,17 +1683,17 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
vi.stubGlobal('location', {
|
||||
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
|
||||
})
|
||||
const client = new FixtureApiClient()
|
||||
const sessions = createSessionClient(client.rpc)
|
||||
const workspaces = createWorkspaceClient(client.rpc)
|
||||
const workspaceRemote = createWorkspaceRemote(client.rpc)
|
||||
const rpc = createFixtureConnectionRpc()
|
||||
const sessions = createSessionClient(rpc)
|
||||
const workspaces = createWorkspaceClient(rpc)
|
||||
const workspaceRemote = createWorkspaceRemote(rpc)
|
||||
await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
|
||||
const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' })
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const hostAbort = new AbortController()
|
||||
const workspaceAbort = new AbortController()
|
||||
const hostFrames = collectValues(
|
||||
openFixtureRemoteEvents(client.rpc, hostAbort.signal),
|
||||
openFixtureRemoteEvents(rpc, hostAbort.signal),
|
||||
hostAbort,
|
||||
frames => frames.length === 1,
|
||||
)
|
||||
@@ -1761,8 +1727,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
|
||||
it('maps attach-failure and dropped-response query scenarios', async () => {
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
|
||||
const partial = new FixtureApiClient()
|
||||
const partialResult = await createSessionClient(partial.rpc).create({
|
||||
const partial = createFixtureConnectionRpc()
|
||||
const partialResult = await createSessionClient(partial).create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-partial'),
|
||||
})
|
||||
@@ -1772,8 +1738,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
})
|
||||
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
|
||||
const dropped = new FixtureApiClient()
|
||||
await expect(createSessionClient(dropped.rpc).create({
|
||||
const dropped = createFixtureConnectionRpc()
|
||||
await expect(createSessionClient(dropped).create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
|
||||
@@ -6,11 +6,9 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts'
|
||||
import { API_PATH, RpcId, apply, inject, type ClientRequest, type HostConnectionHandle } from '../src/index.ts'
|
||||
import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
|
||||
import { provideBrowserCredentials } from './browser-credentials.ts'
|
||||
|
||||
@@ -94,7 +92,6 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
provideBrowserCredentials(ctx)
|
||||
ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return {
|
||||
@@ -131,7 +128,6 @@ describe('connection node half', () => {
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
|
||||
} as AttachmentStore)
|
||||
ctx.provide('apiProxy', {} as ApiProxy)
|
||||
await expect(apply(ctx, { maxRequestBodyBytes: 1024 }))
|
||||
.rejects.toThrow(/must be at least .* aggregate image limit/)
|
||||
expect(routes).toHaveLength(0)
|
||||
@@ -143,7 +139,6 @@ describe('connection node half', () => {
|
||||
const ctx = new Context()
|
||||
provideBrowserCredentials(ctx)
|
||||
ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
@@ -243,7 +238,7 @@ describe('connection node half', () => {
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
|
||||
it('provides a disposable dedicated RPC channel', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
provideBrowserCredentials(ctx)
|
||||
@@ -292,12 +287,11 @@ describe('connection node half', () => {
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
|
||||
it('dispatches claimed /api endpoints and withdraws the claim', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
provideBrowserCredentials(ctx)
|
||||
ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RpcId, transportError } from '../src/rpc.ts'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
rpcErrorSchema,
|
||||
rpcIdSchema,
|
||||
rpcMessageSchema,
|
||||
rpcResultSchema,
|
||||
serverResponseSchema,
|
||||
} from '../src/rpc-schema.ts'
|
||||
import { z } from 'zod'
|
||||
|
||||
describe('Connection RPC schema', () => {
|
||||
it('brands any validated string correlation id', () => {
|
||||
expect(RpcId('abc')).toBe('abc')
|
||||
expect(rpcIdSchema.parse('')).toBe('')
|
||||
expect(() => rpcIdSchema.parse(42)).toThrow()
|
||||
})
|
||||
|
||||
it('folds transport exceptions into an internal failure', () => {
|
||||
expect(transportError(new Error('wire down'))).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'wire down', details: {} },
|
||||
})
|
||||
expect(transportError('raw')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'raw' },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates generic failures and both result branches', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'domain-failure', message: 'failed', details: { id: 'x' } }))
|
||||
.toEqual({ code: 'domain-failure', message: 'failed', details: { id: 'x' } })
|
||||
expect(() => rpcErrorSchema.parse({ code: 1, message: 'failed', details: {} })).toThrow()
|
||||
expect(() => rpcErrorSchema.parse({ code: 'failed', message: 'failed', details: [] })).toThrow()
|
||||
|
||||
const schema = rpcResultSchema(z.object({ n: z.number() }))
|
||||
expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
|
||||
expect(schema.parse({ ok: false, error: { code: 'failed', message: 'x', details: {} } }))
|
||||
.toMatchObject({ ok: false })
|
||||
expect(() => schema.parse({ ok: true, error: {} })).toThrow()
|
||||
})
|
||||
|
||||
it('validates both envelope directions and valueless success', () => {
|
||||
const request = { type: 'client-request', rpcId: 'r1', method: 'settings/describe', payload: { args: {} } }
|
||||
const response = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
|
||||
expect(clientRequestSchema.parse(request).method).toBe('settings/describe')
|
||||
expect(serverResponseSchema.parse(response).rpcId).toBe('r1')
|
||||
for (const message of [request, response]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
|
||||
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
|
||||
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
|
||||
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
|
||||
expect(serverResponseSchema.parse({
|
||||
type: 'server-response', rpcId: 'r1', result: { ok: true },
|
||||
}).rpcId).toBe('r1')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@
|
||||
"src/client/index.ts",
|
||||
"src/client/random-uuid.ts",
|
||||
"src/client/rpc.ts",
|
||||
"src/client/web-api-client.ts",
|
||||
"src/loopback-hostname.ts",
|
||||
"src/rpc.ts"
|
||||
],
|
||||
@@ -39,9 +38,6 @@
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/directory-picker"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"src/invariant.ts",
|
||||
"src/loopback-hostname.ts",
|
||||
"src/rpc-host.ts",
|
||||
"src/rpc-schema.ts",
|
||||
"src/rpc.ts"
|
||||
],
|
||||
"references": [
|
||||
@@ -24,13 +25,16 @@
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ function styleInjectionModule(
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Wire-safe authorization types, free of cordis/service imports so browser type
|
||||
* chains (apiproxy api → client) can consume them without loading this
|
||||
* chains can consume them without loading this
|
||||
* package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-authorization/types
|
||||
*/
|
||||
|
||||
@@ -65,7 +65,6 @@ export const PAGE_ASSETS: readonly string[] = [
|
||||
export const IMAGE_ENTRY_SEEDS: readonly string[] = [
|
||||
'@deepseek-ai/dsh-app-boot',
|
||||
'@deepseek-ai/dsh-cmdline',
|
||||
'@deepseek-ai/dsh-host-apiproxy',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/cordis-plugin-include',
|
||||
'js-yaml',
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../webworker-runtime"
|
||||
},
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
@@ -51,8 +51,8 @@
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** API client whose requests travel the worker tunnel instead of the network. */
|
||||
export class WorkerApiClient extends AbstractApiClient {
|
||||
private readonly tunnel: WorkerTunnel
|
||||
|
||||
/**
|
||||
* Bind the carrier to a tunnel.
|
||||
* @param tunnel - page half of the worker tunnel.
|
||||
*/
|
||||
constructor(tunnel: WorkerTunnel) {
|
||||
super()
|
||||
this.tunnel = tunnel
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one request through the tunnel.
|
||||
* @param input - request URL.
|
||||
* @param init - fetch init; the tunnel honours method, headers, body, and signal.
|
||||
* @returns the reconstructed response.
|
||||
*/
|
||||
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
|
||||
return this.tunnel.fetch(input, init)
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,10 @@
|
||||
*/
|
||||
import { IMAGE_FILE_NAME } from '../image-layout.ts'
|
||||
import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts'
|
||||
import { WorkerApiClient } from './api-client.ts'
|
||||
import { WorkerTunnel, type TunnelFetch } from './client.ts'
|
||||
import { applyIndexInjections } from './apply-injections.ts'
|
||||
import { choosePreviewSource } from './source-chooser.ts'
|
||||
|
||||
export { WorkerApiClient } from './api-client.ts'
|
||||
export { WorkerTunnel, type TunnelFetch } from './client.ts'
|
||||
export { applyIndexInjections } from './apply-injections.ts'
|
||||
export { IMAGE_FILE_NAME } from '../image-layout.ts'
|
||||
@@ -27,7 +25,6 @@ export {
|
||||
/** Transport global the connection plugin reads instead of building an HTTP carrier. */
|
||||
interface ClientTransportGlobal {
|
||||
__DSH_TRANSPORT__?: {
|
||||
createApiClient: () => WorkerApiClient
|
||||
fetch: TunnelFetch
|
||||
openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>
|
||||
loadBundle: (url: string) => Promise<void>
|
||||
@@ -147,7 +144,6 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn
|
||||
)
|
||||
const payload = await tunnel.bootPayload()
|
||||
;(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),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The Node-compatibility table, in one place. Two consumers share it, and they
|
||||
* must resolve to the same module instances:
|
||||
* - the worker vite build aliases these specifiers for code bundled statically
|
||||
* into the worker (vendored loader, apiproxy, …);
|
||||
* into the worker (vendored loader, Connection, …);
|
||||
* - the worker module loader answers `require('node:fs')` from VFS-loaded
|
||||
* modules out of this table, before bare-name resolution.
|
||||
* Anything absent here fails loudly at resolution instead of resolving to an
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `ws` stub. `WebSocketDownlinks` constructs a `WebSocketServer` in a field
|
||||
* initializer as soon as apiProxy is present, so the class must be constructible;
|
||||
* initializer as soon as Connection is present, so the class must be constructible;
|
||||
* no method is ever reached because the fake HTTP server never emits `upgrade`
|
||||
* (the tunnel carries downstream events over the SSE branch instead).
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts'
|
||||
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
||||
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
|
||||
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'
|
||||
@@ -242,19 +243,15 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost {
|
||||
})
|
||||
context = ctx
|
||||
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service')
|
||||
const connection = ctx.get('connection') as HostConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error('webworker host: the tree activated without a Connection 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> }
|
||||
}
|
||||
const shared = ctx.get('connection') !== undefined
|
||||
const handler = directFetchHandler(ctx, toFetchHandler(apiProxy))
|
||||
const handler = connection.createSharedFetchHandler('/api')
|
||||
const usage = loader.usage()
|
||||
console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`)
|
||||
console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`)
|
||||
|
||||
tunnel.serve({
|
||||
directFetch: (request: Request) => handler.fetch(request),
|
||||
@@ -349,33 +346,6 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tunnel's direct API entry.
|
||||
*
|
||||
* The core API surface alone is not the whole `/api` channel: Typert RPC
|
||||
* endpoints (`/api/<service>/<method>`) are served by an interceptor the gateway
|
||||
* registers on the Connection service, and answer 404 from the core routes. The
|
||||
* Connection service composes both halves in `createSharedFetchHandler`, whose
|
||||
* fallback — not the composition — carries network authentication and trust, so
|
||||
* composing it here keeps every interceptor while leaving out the fences the
|
||||
* worker-local direct lane exists to bypass.
|
||||
* @param ctx - Booted host context.
|
||||
* @param core - Fetch handler over the API surface.
|
||||
* @returns Handler covering interceptors and the core surface.
|
||||
*/
|
||||
function directFetchHandler(
|
||||
ctx: HostContext,
|
||||
core: { fetch(request: Request): Promise<Response> },
|
||||
): { fetch(request: Request): Promise<Response> } {
|
||||
const connection = ctx.get('connection') as {
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: { fetch(request: Request): Promise<Response> },
|
||||
): { fetch(request: Request): Promise<Response> }
|
||||
} | undefined
|
||||
return connection?.createSharedFetchHandler('/api', core) ?? core
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped preset root, as the application layer that owns the composition
|
||||
* supplies it.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../client/modules"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
"path": "../../client/connection/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
|
||||
@@ -485,13 +485,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ConnectionConfig',
|
||||
declaration: 'export interface ConnectionConfig {\n backoffBaseMs?: number;\n backoffFactor?: number;\n backoffMaxMs?: number;\n generationReadyTimeoutMs?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionGeneration',
|
||||
declaration: 'export interface ConnectionGeneration {\n readonly id: number;\n readonly host: ConnectionHostInfo;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionGenerationSource',
|
||||
declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: () => void) => Promise<void>;',
|
||||
declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: (host: ConnectionHostInfo) => void) => Promise<void>;',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionGenerationState',
|
||||
declaration: 'export interface ConnectionGenerationState {\n getSnapshot(): ConnectionGeneration | undefined;\n subscribe(listener: () => void): () => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionHandle',
|
||||
declaration: 'export interface ConnectionHandle {\n readonly api: IApiClient;\n readonly isLoopback: boolean;\n readonly hostDescription: HostDescriptionSource;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}',
|
||||
declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionHostInfo',
|
||||
declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionRpcFailure',
|
||||
@@ -503,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ConnectionSinks',
|
||||
declaration: 'export interface ConnectionSinks {\n onConnected?: (description: HostDescription) => void;\n onStateChange?: (state: ConnectionState) => void;\n}',
|
||||
declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConnectionState',
|
||||
@@ -525,14 +537,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'HooksSources',
|
||||
declaration: 'export type HooksSources = Record<string, HostObservable<unknown>>;',
|
||||
},
|
||||
{
|
||||
name: 'HostDescription',
|
||||
declaration: 'export type HostDescription = import(\'@deepseek-ai/dsh-host-apiproxy/api\').ResponseValue<\'host.describe\'>;',
|
||||
},
|
||||
{
|
||||
name: 'HostDescriptionSource',
|
||||
declaration: 'export interface HostDescriptionSource {\n getSnapshot(): HostDescription | undefined;\n subscribe(listener: () => void): () => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'HostObservable',
|
||||
declaration: 'export type HostObservable<T> = ObservableSnapshot<T>;',
|
||||
@@ -651,7 +655,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'RemoteStream',
|
||||
declaration: 'export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>> {\n constructor(private readonly connection: Pick<ConnectionHandle, \'hostDescription\'>, private readonly options: RemoteStreamOptions<Item>);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise<void>;\n [Symbol.asyncIterator](): AsyncIterator<RemoteStreamItem<Item>>;\n}',
|
||||
declaration: 'export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>> {\n constructor(private readonly connection: Pick<ConnectionHandle, \'generation\'>, private readonly options: RemoteStreamOptions<Item>);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise<void>;\n [Symbol.asyncIterator](): AsyncIterator<RemoteStreamItem<Item>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'RemoteStreamCarrierError',
|
||||
|
||||
@@ -2133,9 +2133,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
ownerProps: [
|
||||
'/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'Wire',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useSessions: UseSessions',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Wire-safe approval identifiers and outcome vocabulary, free of
|
||||
* cordis/service imports so browser type chains (apiproxy api → client) can
|
||||
* cordis/service imports so browser type chains can
|
||||
* consume them without loading this package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-user-approval/types
|
||||
*/
|
||||
|
||||
@@ -92,7 +92,6 @@ describe('client bundle purity gate', () => {
|
||||
})
|
||||
|
||||
it('lets inline-safe wire layers inline', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
|
||||
expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
|
||||
@@ -218,10 +217,10 @@ describe('client bundle debug artifacts', () => {
|
||||
if (transform === undefined) throw new Error('client sourcemap path transform missing')
|
||||
|
||||
const sourceMapPath = clientSourceMapPath('client/connection')
|
||||
const workspaceSource = transform('../../../host/apiproxy/src/api/rpc.ts', sourceMapPath)
|
||||
expect(workspaceSource).toBe('../../../packages/host/apiproxy/src/api/rpc.ts')
|
||||
const workspaceSource = transform('../src/rpc.ts', sourceMapPath)
|
||||
expect(workspaceSource).toBe('../../../packages/client/connection/src/rpc.ts')
|
||||
const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map')
|
||||
expect(resolved.pathname).toBe('/packages/host/apiproxy/src/api/rpc.ts')
|
||||
expect(resolved.pathname).toBe('/packages/client/connection/src/rpc.ts')
|
||||
|
||||
const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js'
|
||||
expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource)
|
||||
|
||||
Reference in New Issue
Block a user