232 lines
9.2 KiB
TypeScript
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)
|
|
}
|