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.
| Package | Role (node half) | Role (browser half) |
|---|---|---|
@deepseek-ai/dsh-client-connection | Mounts the /api prefix (trust fence + browser-session auth + HTTP bridge) and the dsh-auth cookie exchange on the web server | HTTP RPC client + ConnectionHandle (registerGenerationSource, start), fixture transport, ctx.connection |
@deepseek-ai/dsh-client-store | — | React-free observable/snapshot-store primitives (createSnapshotStore, SnapshotStore<T>) powering every client-side store |
@deepseek-ai/dsh-client-modules | Scan dsh.client, compose the boot graph, serve /plugins/??… combo bundles, clientModules | ClientModuleSystem — the lazy module table |
@deepseek-ai/dsh-client-hmr | Stat-poll bundles, serve /plugins/events SSE | EventSource hot-swap of plugin fibers |
@deepseek-ai/dsh-client-web | — | Shell kernel: AppWebEntry, framework-free BootPage, platform seed table |
@deepseek-ai/dsh-client-ui-renderer | — | SlotRegistry (a Cordis Service), React slot bindings, ctx.uiRenderer.mount |
The old dsh-client-runtime and dsh-client-web-react packages are deleted. The runtime's SlotRegistry lives in packages/client/ui-renderer/src/client/registry.ts (class SlotRegistry extends Service, ctx.slots); the React bindings split into ui-renderer (the renderer itself), ui-chat (conversation UI), and ui-brand-official (brand seat occupants); and the new dsh-client-store supplies the React-free pack that ui-renderer and every widget store build on.
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: unary RPC up, one mux WebSocket down
The host half binds everything under API_PATH = '/api' (packages/client/connection/src/api-path.ts) — that constant is now the only path constant; the old MUX_EVENTS_PATH/HOST_EVENTS_PATH (/api/events.mux, /api/events.host) are gone.
export const API_PATH = '/api'The browser half (packages/client/connection/src/client/) ships two carriers behind one ClientConnectionRpc contract:
| Carrier | Up-link | Down-link | When |
|---|---|---|---|
createWebConnectionRpc() | fetch POST to /api/<method> | one shared Gateway WebSocket; per-plugin streams multiplexed on it | real page |
createFixtureConnectionRpc() | in-memory | — | ?fixture=... boot mode (page URL carries fixture) |
The old WebApiClient/AbstractApiClient classes and the serverRequestSchema/hostFrameSchema/muxFrameSchema set from @deepseek-ai/dsh-host-apiproxy/api are gone (that package is deleted). Unary calls now ride typed envelopes from packages/client/connection/src/rpc.ts — ClientRequest ({ type: 'client-request', rpcId, method, payload }) answered by ServerResponse ({ type: 'server-response', rpcId, result }), with RpcMessage = ClientRequest | ServerResponse — validated by clientRequestSchema/serverResponseSchema (rpc-schema.ts).
Downstream event streams are Typert Remote streams over one shared WebSocket: the browser opens ws(s)://<origin>/api/remote.mux (REMOTE_STREAM_MUX_PATH, owned by packages/api/gateway), and the shared RemoteStreamMuxClient fans frames out to independently cancellable per-stream readers — RemoteStream/RemoteSnapshotStream/RemoteJournalStream (see API Layer). A stream failure or socket close ends the carrier's generation, which the gateway turns into a reconnect.
Browser Node host
├─ fetch POST /api/session.prompt ──► /api prefix (trust + auth, 300 MiB cap)
├─ fetch POST /api/settings.mutate ──► exact fetch route → Typert gateway
└─ WS /api/remote.mux ◄────────────── gateway stream mux (Ping keepalive)
├─ session/control ├─ session/follow └─ workspace/follow
└─ EventSource /plugins/events ◄────── (HMR only) graph/rebuilt framesType doors: a browser package imports the wire vocabulary from @deepseek-ai/dsh-client-connection/client (ConnectionHandle, RpcId, RpcRequest, RpcResponse, RpcResult, StreamChunk, MessageId, …) and the assembled Remote types from @deepseek-ai/dsh-api-remotes/client — never a Host package root.
Trust fence and browser-session auth
/api no longer means "no credentials". The route is guarded by two gates in series:
- Browser-session authentication (
browser-auth.ts) — the process mints a per-process HMAC launch token and prints the authenticated URL (?token=…); a browser opening it exchanges the token once for a signeddsh-auth-<authority>cookie (HttpOnly; SameSite=Strict, authority-bound, default 30-day lifetime), and every request without a valid cookie gets 401.ctx.connection.authorizeIndexgates index serving by the same rule. - The trust fence (
api-request-trust.ts) —isTrustedApiRequest/assertTrustedAuthoritystill reject any request whoseHostis neither loopback nor a configuredtrustedHostsauthority (the DNS-rebinding defense). The oldPRIVILEGED_METHODSloopback pin list is gone (the model catalog's LAN exposure was folded into the ordinary trust model); only the two helpers remain.
Connection lifecycle
The single recovery loop is ConnectionController (client/connection.ts), but the API Gateway owns it: the gateway registers the long-lived generation source with ctx.connection.registerGenerationSource(source), then calls ctx.connection.start(sinks, config) (a second start throws). The sinks are:
connection.start({
onConnected: (host) => …, // generation source reported ready, first connect included
onStateChange: (state) => …, // 'connected' | 'disconnected' | 'connecting', deduplicated
onReconnectRequested: () => …, // start one fresh physical-carrier attempt before each retry
})ConnectionState is exactly 'connected' | 'disconnected' | 'connecting' (published after the first attempt has an outcome; equal states are deduplicated). Backoff and readiness defaults (client/connection.ts:24,31):
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
backoffBaseMs: 500, backoffFactor: 2,
backoffMaxMs: 10_000, generationReadyTimeoutMs: 3_000, // was streamOpenTimeoutMs
}The old readiness handshake (host.describe + two stream onOpens under streamOpenTimeoutMs) is replaced by the generation-ready handshake: the registered generation source attaches its incremental listeners first and reports ready once delivery is live, bounded by generationReadyTimeoutMs. Any loss drops to disconnected and retries with jittered exponential backoff; onStateChange !== 'connected' retracts the current generation snapshot so consumers never render a stale generation's facts. 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 that now ship inside the controller packages' client faces:
ctx.sessionsis typedISessions(packages/api/session-controller/src/client/contract/sessions.ts), implemented byClientSessions(sessions/service.ts) in the session-controller's browser half — an object layer overctx.remote.sessionunary calls plus Gateway stream factories (createSessionControlStream,SessionEventStream).ctx.workspacesisIWorkspaces(packages/api/workspace-controller/src/client/service.ts,WorkspaceController extends Service). The concrete faces use the same snapshot-store discipline (createSnapshotStorefromdsh-client-store); 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) is the second, RPC-idiom mirror: a Typert client over the Gateway. Contributions are mounted withctx.remote.$mount(contribution)(the 12-name assembly lives inpackages/api/remotes/src/client/index.ts), and forwarded Host events arrive through the gateway's internal event stream and are delivered toctx.remote.$on(...)subscribers. The old$dispatchmechanics andhost/remote-eventframes are gone.
Agent scoping uses Typert: createScope/scopeOf (session-controller's scope.ts) mint one Agent scope per session (agent id === session id), and Agent-scoped Remote events resolve through it.
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__ (bootInjections → structured IndexInjection rows). 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):
export interface WebBootEntry { id, url /* /plugins/<id>/client.js?rev=… */, rev, inject?, immediately? }
export interface WebBootGraph { rev, entries: WebBootEntry[] }Bundles are served as combo URLs — /plugins/??<pkg>/client.js,<pkg2>/client.js&rev=<hash> — with cache-control: public, max-age=31536000, immutable (only HMR and source maps use the per-plugin /plugins/<id>/client.js URL). 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/renderer) 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
| Provider | Consumed by | Kind of dependency |
|---|---|---|
dsh-client-connection (ctx.connection) | api-remotes, ui-*, apps/web boot | service + typed envelopes |
dsh-client-store | ui-renderer, every widget store | React-free snapshot store |
dsh-client-ui-renderer (ctx.slots, ctx.uiRenderer) | every ui module | SlotRegistry service + renderer |
dsh-client-modules (browser) | the shell kernel, dsh-client-hmr | module table |
dsh-client-modules (node) | web-app bundle, dsh-client-hmr (node) | clientModules service |
dsh-client-ui-slots | ui-renderer bindings, every slot registrant | typed 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 prefix and the browser-session cookie exchange, and is therefore also a bundle/web-app dependency.
The browser faces import types from @deepseek-ai/dsh-client-connection/client, @deepseek-ai/dsh-api-remotes/client, and the owner packages' ./types subpaths — the browser-safe channels — and never a Host package root, which would drag Host-only symbols into the page bundle. ui-renderer also owns the cordis Events merge: slots/changed(key) fires on slot re-mutation, and connection/reset() drops wire-derived caches.
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 pre-connect span reports no state — the UI treats "no state yet" as connecting rather than an outage, and a disconnected/connecting transition retracts the current generation snapshot. The generation itself is served by ConnectionGenerationState (getSnapshot + subscribe), a generation-scoped observable that ConnectionController republishes every onConnected.
Packages in this section
| Package |
|---|
@deepseek-ai/dsh-client-connection |
@deepseek-ai/dsh-client-store |
@deepseek-ai/dsh-client-modules |
@deepseek-ai/dsh-client-hmr |
@deepseek-ai/dsh-client-web |
@deepseek-ai/dsh-client-ui-renderer |
@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/webto serveddist. - Frontend: Localization — how
ctx.localecatalogs en/zh and installs the rendererLocaleFace. - Frontend: Settings Schema & Forms — the settings model layer riding the same wire.
- LLM platform: API Layer — the
/apiroute, the remote-stream mux, and browser-session auth. packages/client/connection/src/client/connection.ts—ConnectionController, generation-ready handshake, backoff.packages/client/modules/src/client/system.ts—ClientModuleSystem(the lazy-CJS module table) andpackages/client/hmr/src/client/index.ts(the in-place fiber swap).