Skip to content

The web frontend is built in two disjoint halves that meet at runtime. The shell (apps/web over @deepseek-ai/dsh-client-web) is a compiled Vite application; the plugins are lazily loaded client.js bundles. This page follows the whole chain: dsh web → served distindex.html → shell boot → slot render of the UI.

The two build targets

TargetPackageBuilt byWhat it ships
Plugin bundlesevery dsh.client packagetsdown (packages/client/tsdown.client.ts)one ./client.js per package, plus map + package.json exports["./client"]
The shell@deepseek-ai/dsh-web-frontend = apps/webVite (apps/web/vite.config.ts)dist/index.html + hashed assets/ chunks

apps/web is not a standalone application — its Vite config throws if you try a bare serve/preview (rejectStandaloneServe) because only the host injects window.__DSH_BOOT__. The entry is thin:

ts
// apps/web/src/main.ts
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
if (el === null) throw new Error('web app: missing #root')
void new AppWebEntry(el).run()

Vite's manifest.webmanifest and favicon.svg live in apps/web/public/; the docs/web-styling.md guide documents the CSS conventions the shell obeys.

Serving the built app

The host half of the web transport (patches in bundle/web-app/cordis.patch.yml) mounts the web rows. The webserver row (@deepseek-ai/dsh-host-webserver, default 127.0.0.1:3080) registers the /api gateway and the WebSocket/SSE downlinks; the web-runtime row mounts @deepseek-ai/dsh-web-app (the bundle's glue plugin, packages/bundle/web-app/src/index.ts).

@deepseek-ai/dsh-web-app resolves the frontend dist (workspace knowledge, never user config) by require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html'), then mounts @deepseek-ai/dsh-host-frontend-static over the web server's fallback seat (registerFallback). frontend-static (packages/host/frontend-static/src/index.ts) serves with locked semantics: traversal outside the dist root → 403, any miss → index.html with 200 (SPA routing), non-GET/HEAD → 405. Every index response flows through ctx.webServer.applyIndexTaps, which runs the boot-manifest and boot-theme injections.

The dsh web CLI seam

@deepseek-ai/dsh-web-app/startup (packages/bundle/web-app/src/startup.ts) parses the --profile web flag family (--host, --port, --trusted-host) with a commander command and provides immutable values as WEB_STARTUP_SERVICE. Flag-configured rows inject that service (resolving after it exists) — e.g. the webserver row's host/port defaults and the connection row's trust fence:

ts
context: `host: 127.0.0.1, port: 3080 (webStartup overrides), trustedHosts: ctx.webRuntime.trustedHosts`

--host 0.0.0.0 is rejected loudly — exposing RCE to the network is indefensible; --port 0 lets the OS pick one. After the server binds, the web runtime samples LAN IPv4 literals once (resolveLanTrust), publishes webRuntime, and — when the Loader tree settles — prints the readiness URL line (dsh web: http://127.0.0.1:3080, plus (LAN: …) when bound to all interfaces). Supervisors and the keyless CLI smoke RPC as soon as they observe that line.

Two index taps

Every index.html response runs two registered index transforms, in composition order:

  1. client-modules boot-manifest tapinjectBootManifest inserts <script>window.__DSH_BOOT__ = {…}\u003c…</script> as the first child of <head>, escaping < so plugin-controlled strings cannot break out of the script element. This is the wire the kernel parses before any Cordis exists.
  2. ui-theme boot-theme tapinjectBootTheme drops a <script> right after <body> that resolves system and writes colorScheme + data-ds-dark-theme so first paint is correctly themed.

Both run on the SPA fallback path too (any deep route serves index.html with these taps), so a refresh on /conversation/… re-injects the same boot state.

txt
dsh web (--host --port --trusted-host)
   └─ @deepseek-ai/dsh-web-app            resolve dist → frontend-static
        └─ @deepseek-ai/dsh-host-webserver  fallback seat
             └─ applyIndexTaps
                  ├─ client-modules: window.__DSH_BOOT__ injection
                  └─ ui-theme:  boot-theme <script> after <body>

The boot sequence

AppWebEntry.run() (packages/client/web/src/boot.tsx) is the shell kernel. Stage order:

  1. Parse window.__DSH_BOOT__ into a two-view BootManifest (module rows + plugin rows).
  2. Build ClientModuleSystem over the module rows with the platform staticModules (react family, cordis, ui-slots, ui-primitives, ui-attachment, schema-form from seed.ts), then register the shell-own modules @deepseek-ai/dsh-client-app-shell and @deepseek-ai/dsh-client-modules (registerStatic).
  3. Render the loading page (AppRoot) immediately — a shell self-sufficiency rule: the page must work while plugins load.
  4. new Context(), mount the vendored Cordis Loader, inject the module system as loader.internal, create one loader entry per plugin row plus the app-shell assembly entry, then loader.await().
  5. A full fiber sweep (assertEntriesActive) fails loud listing which entry is pending (waiting on a missing service) or failed.
  6. Flip settled so AppRoot switches to the real UI in one pass.

Rows marked immediately in the boot graph are prefetched in parallel with Loader mounting (factory registration only), so the cross-package synchronous require edges can resolve before any entry materializes; per-row prefetch failures stay silent because the create-side import reloads and reports them.

app-shell (packages/client/web/src/app-shell.ts) runs only after its inject set is active; it installs createSlotRenderer() (boot-once) and provides appShell.renderApp, which calls the one ctx-level renderSlot('root', {}):

ts
// app.tsx
return () => (<>
  <SessionDocumentTitle />
  {ctx.slots.renderSlot('root', {})}
</>)

The AppWebEntry seam for tests

AppWebEntry takes an optional BootSeams object (a Pick<ClientModuleSystemOptions, 'loadBundle'>) that lets a jsdom-enabled test replace the <script> bundle-transport hook with an in-process stand-in. Production passes no seams and uses the default same-origin <script src> loader (defaultLoadBundle in system.ts). Everything else in the kernel — parseBootManifest, ClientModuleSystem, AppRoot, staticModules — is testable against the same __DSH_BOOT__ wire because the boot is fully decoupled from any real host until connection.start is called.

This is also why apps/web/src/main.ts can be three lines: the shell library owns the boot; the app owns finding the mount node.

How plugins add UI

Plugins add UI entirely through slots + modules — the shell has no knowledge of any specific feature. A plugin entry (owning _apply on a Cordis context) calls ctx.slots.register({ name: '…', … }, Component); the renderer composes its props and mounts it when the slot's outlet renders root. The root slot's sole occupant is ui-layout's AppFrame, which then renders its sidebar/conversation/details/shell.overlay children. So "adding UI" usually means picking a declared seat (e.g. a new conversation.chat.node key or a shell.overlay id) and registering — see UI modules.

The AppRoot gate and fail-loud boot

AppRoot (packages/client/web/src/AppRoot.tsx) is a pure kernel component with zero plugin dependencies — the fail-loud presentation must not depend on the system whose failure it reports. It subscribes to three kernel signals: settled, status (per-entry fiber state), and error (the boot rejection message). Before settlement it renders a loading card (HARNESS wordmark + spinner); on failure it lists every entry whose fiber is failed and the sweep error message, staying on the loading page — there is no partial UI.

The success path is one switch: when settled flips, AppRoot renders props.renderApp(), which the boot closure routes through ctx.appShell.renderApp(). Because app-shell is itself a loader entry (the only shell-own module), the same sweep that activates plugins also activates the assembly that renders them.

The Vite build in detail

apps/web/vite.config.ts does four notable things:

  1. Rejects standalone servingrejectStandaloneServe throws unless the bundle is served by dsh web (which injects window.__DSH_BOOT__), so a bare dev server can never expose a boot-manifest-free shell.
  2. Hashes every workspace module — the shell bundle is the only workspace code Vite compiles: plugin packages are never bundled here (shell self-sufficiency); they arrive at runtime as ./client.js bundles through the module system.
  3. Manual vendor chunks — math (KaTeX), syntax highlight (shiki), and markdown (micromark/mdast) go into vendor (with assets/langs/ for lazy grammars, assets/fonts/ for KaTeX); the three boot grammars (typescript, shellscript, json) ride vendor so the initial load stays small.
  4. Source-alias resolution — workspace packages resolve to src so CSS flows through Vite (not the CSS-externalized lib bundle); @deepseek-ai/dsh-client-webpackages/client/web/src/boot.tsx, plus web-react, ui-slots, ui-primitives, ui-attachment, schema-form, and modules/client. node:module is stubbed with a throwing browser stand-in, and process.versions.node/process.execArgv/CORDIS_SHARED are pinned to keep the vendored loader's Node probes inert in the browser.

The built dist/ therefore contains index.html, assets/index-*.js, assets/vendor-*.js, assets/langs/*.js (lazy Shiki grammars), assets/fonts/*.{woff2,woff,ttf} (KaTeX faces), and the public/ passthroughs manifest.webmanifest + favicon.svg. Because only the index chunk re-hashes when shell source changes, returning clients keep the cached vendor chunk — the manual-chunk split is a cache discipline as much as a code organization.

Plugin bundles, by contrast, are served under /plugins/<id>/client.js?rev=<sha1> by @deepseek-ai/dsh-client-modules (node half) with cache-control: no-cache. The rev query is the cache-buster: a rebuilt bundle (via pnpm run dev:web + the HMR chain) gets a fresh hash, so the browser never sits on a stale plugin. Revs converge through clientModules.rebuilt(id) and cascade to the page over the HMR SSE channel — see Client runtime.

Theming

@deepseek-ai/dsh-client-ui-theme owns a durable ui-theme settings section (preference = light | dark | system, default system) and a browser ThemeRuntime that resolves system via matchMedia('(prefers-color-scheme: dark)'). The base palette is tokenized CSS custom properties (--dsw-alias-*) in packages/client/ui-theme/src/styles/.

Before the plugin tree activates, the host injects a boot theme script into every index.html response (boot-theme.ts): it reads the durable preference from the Host, resolves system in the browser, and writes document.documentElement.style.colorScheme plus body[data-ds-dark-theme] — the attrs that select the dark palette.

Once the client is up, ui-layout's ThemePresenter (theme-presenter.ts) projects each resolved ThemeSnapshot onto the document: root color-scheme, the dark attribute from active.colorScheme (never the id), alias-token overrides as inline CSS variables on body, and one presenter-owned meta[name="theme-color"]. Third-party themes register { id, colorScheme, tokens } or stack overrideTokens(source, {token: {light,dark}}). The Appearance row (AppearanceRow.tsx) is a settings.general.item entry.

The token model and dark mode

Base variable sheets live in packages/client/ui-theme/src/styles/ (base.css, design-platform.css, scrollbar.css, shiki.css, gradient-shadow-text.css). The design system is two-palette token CSS: light and dark palettes both carry the same --dsw-alias-* token names, and body[data-ds-dark-theme] selects the dark values with no class-name rewrites. Override layers (theme packs or model-authored skinning) add a third axis on top — inline --dsw-alias-* variables on body whose {light, dark} pair is chosen by the active colorScheme. Because tokens are the only style currency, a theme that re-paints the app never touches component CSS — it registers tokens.

The boot <script> guarantees the first paint is already correctly colored (no flash of light-theme before the plugin tree resolves system), while ThemeRuntime's prefers-color-scheme listener re-emits whenever the OS scheme flips while the preference is system. The durable ui-theme section (z.object({ preference })) lets the Appearance row persist and reload the choice across sessions.

Packages in this section

Package
@deepseek-ai/dsh-web-frontend (apps/web)
@deepseek-ai/dsh-client-web
@deepseek-ai/dsh-client-web-react
@deepseek-ai/dsh-client-ui-theme
@deepseek-ai/dsh-client-ui-layout
@deepseek-ai/dsh-web-app (bundle/web-app)
@deepseek-ai/dsh-host-frontend-static
@deepseek-ai/dsh-host-webserver
@deepseek-ai/cordis (vendored)

Further reading