refactor(client): expose loopback state through connection

This commit is contained in:
imccyu
2026-08-03 18:34:00 +08:00
parent e47ad8e393
commit fc5ee9f786
16 changed files with 38 additions and 36 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69
README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60
README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9
README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -19,7 +19,6 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./loopback-hostname": "./src/loopback-hostname.ts",
"./src/*": "./src/*",
"./package.json": "./package.json"
},

View File

@@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -48,6 +49,8 @@ export const inject: string[] = []
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -64,11 +67,13 @@ export interface ConnectionHandle {
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -1,10 +1,7 @@
/**
* Browser-safe, zero-dependency loopback classification shared by the `/api`
* Host fence and browser welcome-persistence selection. The dedicated
* `./loopback-hostname` source subpath is inlined into client bundles instead
* of loaded by plain Node, so this module must not add Node-only or runtime
* dependencies.
* @module @deepseek-ai/dsh-client-connection/loopback-hostname
* Host fence and the package's `ctx.connection` state. The predicate stays
* package-internal; client plugins consume the derived state through Cordis.
*/
/**

View File

@@ -8,7 +8,7 @@ import { apply, 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?: { search: string } }
type Win = { location?: { hostname: string; search: string } }
afterEach(() => {
delete (globalThis as Win).location
@@ -24,20 +24,28 @@ async function mount(): Promise<ConnectionHandle> {
describe('connection client apply', () => {
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
;(globalThis as Win).location = { search: '' }
;(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 () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
delete (globalThis as Win).location
expect((await mount()).api).toBeInstanceOf(WebApiClient)
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('reports non-loopback page authority through the connection handle', async () => {
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
expect((await mount()).isLoopback).toBe(false)
})
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
@@ -46,7 +54,7 @@ describe('connection client apply', () => {
})
it('WebApiClient carries requests over globalThis.fetch', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []

View File

@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }

View File

@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }

View File

@@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
* 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|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/**
* Documented TEMPORARY exemption, not a platform module (hence not in

View File

@@ -8,7 +8,6 @@
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -42,10 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin (shell chrome + General copy). */
const NS = 'settings'
function welcomePersistence(): 'host' | 'memory' {
return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory'
}
/**
* Required services (cordis fiber inject). The target slots are declared by
* ui-settings' apply, whose activation order relative to this one is NOT
@@ -66,7 +61,7 @@ export function apply(ctx: ClientContext): void {
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence())
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,

View File

@@ -1,6 +1,6 @@
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
@@ -16,8 +16,6 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
afterEach(() => { vi.unstubAllGlobals() })
/** The five seats this plugin fills (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
@@ -27,7 +25,7 @@ const SEATS = [
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
@@ -49,7 +47,7 @@ async function bench() {
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
@@ -162,8 +160,7 @@ describe('ui-settings-general apply', () => {
})
it('keeps remote welcome acknowledgement process-local', async () => {
vi.stubGlobal('location', { hostname: '192.0.2.20' })
const b = await bench()
const b = await bench(false)
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!