Files
deepseek-harness/packages/client/connection/src/client/index.ts
T

232 lines
9.2 KiB
TypeScript

/**
* Browser wire client. The plugin selects fixture or HTTP transport, provides
* 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,
type ConnectionGenerationSource,
type ConnectionSinks,
type ConnectionState,
} from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A connection generation was established. Wire-derived caches must
* repull; long-lived streams own their own resume and baseline lifecycle.
* @mode emit
*/
'connection/reset'(): void
}
}
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, HostApi,
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, RpcMessage,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
SettingsApi,
ConfigurableProviderView, DiscoveredModelView, LlmApi,
} from './api.ts'
export {
RpcId,
AbstractApiClient,
transportError,
} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState }
export type {
ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
} 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
}
/** Required services (none — this is the wire root). */
export const inject: string[] = []
/**
* Carrier override installed on the page global before plugin boot. The served
* web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
* different physical transport (the worker preview's postMessage tunnel)
* 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. */
openStream?: RpcStreamOpen
/**
* Bundle transport for the module system, present when the carrier also owns
* bundle bytes (the worker tunnel). Absent in the served web app, whose
* bundles load over HTTP.
*/
loadBundle?(url: string): Promise<void>
/**
* The transport owner declares the page owns the Host outright: the Host
* runs inside a worker this page spawned, so no other party can reach it and
* the loopback stand-in for "the operator's own machine" is vacuous.
* `ctx.connection.isLoopback` then reports the privileged surface reachable
* regardless of the page authority. Only a shell that assembles its own
* transport can set this; served pages never carry the global at all.
*/
ownsHost?: boolean
}
/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
interface ClientTransportGlobal {
__DSH_TRANSPORT__?: ClientTransportHooks
}
/**
* The ctx.connection service API: the API client plus a one-shot controller
* starter. API Gateway supplies generation readiness and reset callbacks;
* 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
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
* Register the sole source defining Host generations. The source reports
* ready only after its incremental listeners are attached.
* @param source - long-lived generation source owned by the push carrier.
* @returns disposer withdrawing the source and stopping an active loop.
*/
registerGenerationSource(source: ConnectionGenerationSource): () => void
/**
* Start the connect/reconnect loop with the consumer's state callbacks.
* API Gateway owns the loop; a second call throws.
* @param sinks - connection-state callbacks.
* @param config - reconnect/backoff tunables.
* @returns stop handle for the loop.
*/
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
}
interface ConnectionOwner {
readonly token: object
readonly source: ConnectionGenerationSource
readonly controller: ConnectionController
}
/**
* Client plugin body: pick the api by page mode and provide ctx.connection.
* @param ctx - client cordis context.
*/
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 transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
let generationSource: ConnectionGenerationSource | undefined
let owner: ConnectionOwner | undefined
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 releaseOwner = (current: ConnectionOwner): void => {
if (owner !== current) return
owner = undefined
current.controller.stop()
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) }
},
},
rpc,
registerGenerationSource(source) {
if (generationSource !== undefined) {
throw new Error('connection: a generation source is already registered')
}
generationSource = source
return () => {
if (generationSource !== source) return
generationSource = undefined
const current = owner
if (current?.source === source) releaseOwner(current)
}
},
start(sinks, config) {
if (owner !== undefined) throw new Error('connection: the stream loop is already owned by another consumer')
const source = generationSource
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, {
...sinks,
onConnected: (next) => {
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)
},
onStateChange: (state) => {
if (state === 'reconnecting') publishDescription(undefined)
if (!ownsGeneration()) return
sinks.onStateChange?.(state)
},
}, config ?? {})
const current = { token, source, controller }
owner = current
controller.start()
return {
stop: () => { releaseOwner(current) },
}
},
}
ctx.provide('connection', handle)
}