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 prefix (trust fence + browser-session auth + HTTP bridge) and the dsh-auth cookie exchange on the web serverHTTP 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-modulesScan dsh.client, compose the boot graph, serve /plugins/??… combo bundles, 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-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.

ts
export const API_PATH = '/api'

The browser half (packages/client/connection/src/client/) ships two carriers behind one ClientConnectionRpc contract:

CarrierUp-linkDown-linkWhen
createWebConnectionRpc()fetch POST to /api/<method>one shared Gateway WebSocket; per-plugin streams multiplexed on itreal 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.

txt
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 frames

Type 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:

  1. 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 signed dsh-auth-<authority> cookie (HttpOnly; SameSite=Strict, authority-bound, default 30-day lifetime), and every request without a valid cookie gets 401. ctx.connection.authorizeIndex gates index serving by the same rule.
  2. The trust fence (api-request-trust.ts) — isTrustedApiRequest/assertTrustedAuthority still reject any request whose Host is neither loopback nor a configured trustedHosts authority (the DNS-rebinding defense). The old PRIVILEGED_METHODS loopback 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:

ts
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):

ts
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.sessions is typed ISessions (packages/api/session-controller/src/client/contract/sessions.ts), implemented by ClientSessions (sessions/service.ts) in the session-controller's browser half — an object layer over ctx.remote.session unary calls plus Gateway stream factories (createSessionControlStream, SessionEventStream). ctx.workspaces is IWorkspaces (packages/api/workspace-controller/src/client/service.ts, WorkspaceController extends Service). The concrete faces use the same snapshot-store discipline (createSnapshotStore from dsh-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 with ctx.remote.$mount(contribution) (the 12-name assembly lives in packages/api/remotes/src/client/index.ts), and forwarded Host events arrive through the gateway's internal event stream and are delivered to ctx.remote.$on(...) subscribers. The old $dispatch mechanics and host/remote-event frames 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):

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 ​

ProviderConsumed byKind of dependency
dsh-client-connection (ctx.connection)api-remotes, ui-*, apps/web bootservice + typed envelopes
dsh-client-storeui-renderer, every widget storeReact-free snapshot store
dsh-client-ui-renderer (ctx.slots, ctx.uiRenderer)every ui moduleSlotRegistry service + renderer
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-slotsui-renderer 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 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/web to served dist.
  • Frontend: Localization — how ctx.locale catalogs en/zh and installs the renderer LocaleFace.
  • Frontend: Settings Schema & Forms — the settings model layer riding the same wire.
  • LLM platform: API Layer — the /api route, 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) and packages/client/hmr/src/client/index.ts (the in-place fiber swap).