Skip to content

The web frontend is not a SPA that owns its state. It is a lean client runtime that draws everything — sessions, workspaces, projections, settings — out of a Node process over a custom wire, then presents those facts through a slot-based UI. This page dissects the browser-side transport and runtime: the packages under packages/client/ and how apps/web plugs into them.

Client/host split

Two faces share one package in the dual-face convention: the node half (the package root, built by the host tsdown) runs in the Host process and composes/merges window.__DSH_BOOT__, serves bundles, and registers HTTP routes; the browser half (./client export, built by the client tsdown) runs in the page. All web packages declare dsh.client metadata so the node half of dsh-client-modules can scan the Loader tree and discover them.

PackageRole (node half)Role (browser half)
@deepseek-ai/dsh-client-connectionMounts the /api gateway + WebSocket downlinks on the web serverHTTP/WebSocket client, ConnectionController reconnect loop, ctx.connection
@deepseek-ai/dsh-client-runtime(empty apply)SlotRegistry, SessionRuntime, WorkspaceRuntime, stream pump sinks
@deepseek-ai/dsh-client-modulesScan dsh.client, compose boot graph, serve /plugins/<id>/client.js, clientModulesClientModuleSystem — the lazy module table
@deepseek-ai/dsh-client-hmrStat-poll bundles, serve /plugins/events SSEEventSource hot-swap of plugin fibers
@deepseek-ai/dsh-client-webShell kernel: AppWebEntry, AppRoot, module-table seeds
@deepseek-ai/dsh-client-web-reactReact bindings: createSlotRenderer, bindSnapshotSelector, SessionProvider

The split follows the client bundle purity gate (packages/client/tsdown.client.ts): plugin bundles may never value-import one another; collaboration flows through Cordis services (ctx.*), so the browser bundles stay decoupled and HMR-able.

The transport: HTTP up, WebSocket down

The host half binds everything under API_PATH = '/api' (packages/client/connection/src/api-path.ts):

ts
export const API_PATH = '/api'
export const MUX_EVENTS_PATH  = `${API_PATH}/events.mux`   // session mux frames
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`  // host frames

The browser half (packages/client/connection/src/client/) ships three carriers with one IApiClient contract:

CarrierUp-linkDown-linkWhen
WebApiClientfetch POST to /api/<method>one downlink WebSocket per stream (events.mux, events.host)real page
FixtureApiClientin-memory?fixture=... boot mode (page URL carries fixture)
createWebConnectionRpc()fetch POSTgeneric low-level RPC channels

WebApiClient (in client/web-api-client.ts) extends AbstractApiClient: unary calls (settings.*, sessions.*, …) go through globalThis.fetch; downstream event streams are downlink-only WebSockets parsed through the shared serverRequestSchema/hostFrameSchema/muxFrameSchema from @deepseek-ai/dsh-host-apiproxy/api. A stream/error payload or socket close ends the generator, which the controller turns into a reconnect.

txt
Browser                              Node host
  ├─ fetch POST /api/session.list ──────►  API gateway (HostConnectionService)
  ├─ POST /api/settings.mutate ─────────►  settings.* (loopback-privileged)
  ├─ WS  /api/events.mux  ◄────────────── mux frame stream
  ├─ WS  /api/events.host◄────────────── host frame stream
  └─ EventSource /plugins/events ◄────── (HMR only) graph/rebuilt frames

Trust fence and auth

/api has no credentials — authentication is a fence, not signed tokens. isTrustedApiRequest/assertTrustedAuthority (api-request-trust.ts) reject any request whose Host is neither loopback nor a configured trustedHosts authority (a DNS-rebinding defense). A subset of methods additionally pins to loopback via PRIVILEGED_METHODS — a non-empty set including settings.describe, settings.mutate, credentials.set, credentials.describe, host.pickDirectory, host.openPath, agentPreset.read/remove, and llm.discoverModels. The model catalog (llm.providers, llm.models) is deliberately not pinned: a LAN client's model picker legitimately needs it.

Connection lifecycle

The single stream loop is ConnectionController (client/connection.ts), which ctx.connection.start(sinks) drives. It opens both streams, waits for a strict readiness handshake (host.describe + both onOpens, guarded by streamOpenTimeoutMs), then emits onConnected; any loss drops to reconnecting and retries with jittered exponential backoff:

ts
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
  backoffBaseMs: 500, backoffFactor: 2,
  backoffMaxMs: 10_000, streamOpenTimeoutMs: 3_000,
}

The controller is consumer-agnostic: ConnectionSinks just delivers frames/state to whoever calls start(). The runtime plugin (src/client/index.ts) is that single consumer:

ts
const loop = connection.start({
  onMuxEnvelope:     (envelope) => sessions.handleMuxEnvelope(envelope),
  onHostEnvelope:    (envelope) => { sessions.handleHostEnvelope(envelope); workspaces.handleHostEnvelope(envelope)
                                     /* forward host/remote-event to ctx.remote.$dispatch */ },
  onConnected:       () => { sessions.handleConnected(); workspaces.handleConnected(); ctx.emit('connection/reset') },
  onStateChange:     (state) => { if (state === 'reconnecting') sessions.handleDisconnected() },
})

Notable frame semantics: on a new generation the host replays a baseline so the resync cannot outrun the subscribed state; on reconnecting the runtime drops generation-scoped interaction state. connection/reset is the Cache-busting event every wire-derived cache (commands directory, queue mirrors) repulls on.

Mirroring host services on the client

Host services never exist in the browser — they are mirrored behind narrow contracts. ctx.sessions is typed as ISessions (contract/sessions.ts) and ctx.workspaces as IWorkspaces (contract/workspaces.ts); the concrete SessionRuntime/WorkspaceRuntime head over createSnapshotStore push-model snapshots (contract/store.ts). Reading flows through snapshot stores and selector hooks; writing calls back over the RPC client. Feature packages are deliberately blocked from the concrete services — widening an interface is the explicit act of widening what features may do.

The Remote (ctx.remote) provides a second, RPC-idiom mirror: each host/remote-event frame is $dispatched to ctx.remote.$on subscribers, and session/<domain> frames update per-session mirrors such as the jobsBySession list.

Agent scoping uses Typert: ctx.typert.contexts.registerClient('agent', …) makes an Agent scope resolve to a session id; createScope/scopeOf (agents/scope.ts) mint one scope per session, with the agent id === session id.

The client module system

ClientModuleSystem (packages/client/modules/src/client/system.ts) is the browser peer of Node's ESM loader. The host node half scans dsh.client packages, hashes their ./client.js bundles (sha1 → 12 hex), and injects the entry graph as window.__DSH_BOOT__ (ClientModuleRegistry.injectBootManifest). The browser kernel constructs the module system over those rows before any Cordis exists, and adopts the client-modules wrapper plugin first so it can provide ctx.modules.

The wire shape (manifest.ts):

ts
export interface WebBootEntry { id, url /* /plugins/<id>/client.js?rev=… */, rev, inject?, immediately? }
export interface WebBootGraph  { rev, entries: WebBootEntry[] }

Resolution branch order (lazy CJS): seed word → memoized record → static registry (shell-own modules) → graph row (fetch + materialize) → factory materialization → throw. A bundle only registers its factory (window.__ModuleLoader__.load); every side effect — CSS injection included — runs inside the factory closure at first require, recursively materializing dependencies. This is what makes HMR safe: re-running a bundle is pure registration.

HMR in the browser

dsh-client-hmr (packages/client/hmr/src/client/index.ts) listens on EventSource('/plugins/events'). The node half stat-polls bundle mtimes/sizes every pollIntervalMs (default 500), re-hashes changed bundles, and pushes SSE frames {type:'graph'} / {type:'rebuilt', id, rev}.

On a rebuilt frame the browser reloads that entry in place: invalidate() the stale factory → prefetch() the fresh bundle → registry-first tear-down of the old fiber → drain its disposers → remove owned <style data-plugin> tags → entry.refresh(). Because activation order is fiber inject-waiting, reloading a data-layer plugin (connection/runtime) cascades into its UI dependents natively. Shell changes still mean a full page reload — only dsh.client plugin entries are hot-swappable. Failures never roll back.

Package relationships at a glance

ProviderConsumed byKind of dependency
dsh-client-connection (ctx.connection)runtime, ui-*, apps/web bootservice + IApiClient
dsh-client-runtime (ctx.sessions/workspaces/slots)every ui module (standard hooks)services + slot contracts
dsh-client-modules (browser)the shell kernel, dsh-client-hmrmodule table
dsh-client-modules (node)web-app bundle, dsh-client-hmr (node)clientModules service
dsh-client-ui-slotsweb-react bindings, every slot registranttyped contracts (SlotMap), zero runtime code

dsh-client-connection is the one browser package that also runs a host half — its node face binds the /api gateway and is therefore also a bundle/web-app dependency.

The browser faces import types from @deepseek-ai/dsh-host-apiproxy/api and …/client — the browser-safe channels — and never the apiproxy package root, which would drag bootHost/cordis into the page bundle. runtime also merges several cordis Events: slots/changed(key) and connection/reset() are the two the runtime itself owns, emitted for slot re-mutation and generation loss respectively.

Fixture mode

The connection plugin selects its carrier from the page URL: if it carries a fixture query parameter, conn is a FixtureApiClient (in-memory, ?fixture), and the host-description source resolves rpc from fixtureClient.rpc. This is how the browser tree boots in test/jsdom contexts without any wire or Node process — the same ConnectionHandle contract is served by a fixture transport, so the runtime layer exercises identical code paths. isLoopback is true for non-browser contexts by default; the settings scope in memory mode switches to mode: 'memory' for such clients (persistence is loopback-only).

The coarse ConnectionState you can render on is exactly 'connected' | 'reconnecting', deduplicated so onStateChange fires only on actual transitions. The pre-connect span reports nothing — the UI treats "no state yet" as connecting rather than an outage, and a reconnecting state retracts the current hostDescription snapshot so consumers never render a stale generation's facts. The description itself is served by HostDescriptionSource (getSnapshot + subscribe), a generation-scoped observable that connection.start() republishes every onConnected.

Packages in this section

Package
@deepseek-ai/dsh-client-connection
@deepseek-ai/dsh-client-runtime
@deepseek-ai/dsh-client-modules
@deepseek-ai/dsh-client-hmr
@deepseek-ai/dsh-client-web
@deepseek-ai/dsh-client-web-react
@deepseek-ai/dsh-client-ui-slots

Further reading

  • Frontend: UI modules — what the runtime mirrors make available to the shell's slots.
  • Frontend: The web frontend — the full boot chain from apps/web to served dist.
  • Frontend: Localization — how ctx.locale catalogs en/zh and installs the renderer LocaleFace.
  • Frontend: Schema form — schema-driven settings editors that consume the same wire envelope.
  • packages/client/connection/src/client/connection.tsConnectionController, the reconnect loop and backoff.
  • packages/client/modules/src/client/system.tsClientModuleSystem (the lazy-CJS module table) and packages/client/hmr/src/client/index.ts (the in-place fiber swap).