refactor(gui): rebuild the client loading kernel as dsh-client-modules with a two-phase boot

The module system moves out of dsh-client-runtime (./loader retired) into
its own package: a lazy CJS table where executing a bundle only registers
its factory and materialization happens at first require, memoized, with
recursive requires self-ordering. ClientModuleSystem is a class; index.ts
keeps the types and a thin factory. Boot is two-phase: phase one prefetches
the immediately tier in parallel (registration only, failures deferred to
phase two's loud import); phase two mounts the vendored Loader with the
module system as internal, creates one entry per graph row plus the
app-shell pseudo-row the kernel appends itself, and settles on an
all-ACTIVE sweep. The shell kernel is self-sufficient: hand-rolled
loader-status stores, no plugin value imports, platform seed list single-
sourced in platform.ts.
This commit is contained in:
imccyu
2026-07-23 21:55:39 +08:00
parent 15fde82f80
commit b58f0989f9
30 changed files with 1064 additions and 1015 deletions

View File

@@ -1,8 +1,12 @@
# @deepseek-ai/dsh-client-web
Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader machinery (statically held; a loader cannot load itself), pure-library module-table seeding, AppRoot (boot loading page → settled → full UI in one switch), and the SessionProvider/scopedSlots assembly closure. The vite application entry lives in apps/web and only calls `bootWebShell`. Contract: api-contracts v3 §9.3.
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
@@ -16,6 +20,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **One-shot rendering by design** — the UI waits for `loader.settled()`; a single plugin failure keeps the loading page with a loud error, no partial availability (progressive rendering returns with its own project).
- **No HMR** — the dev loop is tsdown watch + manual refresh for plugins; vite serves only the shell.
- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
- **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-web",
"description": "Web shell library: bootWebShell (loader holding + module-table seeding + AppRoot gate + plugin assembly), consumed by the apps/web vite entry",
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -20,8 +20,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
@@ -30,6 +29,8 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
@@ -37,6 +38,7 @@
"typescript": "^6.0.3"
},
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},

View File

@@ -1,39 +1,46 @@
/**
* Shell root: boot loading page → (loader settled) → real UI in one switch.
* Pure shell component with zero plugin dependencies — before settled it may
* only rely on itself; the real UI is produced by the boot assembly closure
* (renderApp) once every plugin is active. A failed plugin keeps the loading
* page and lists the failures (fail loud, no partial UI).
* Shell root: boot loading page → (boot settled) → real UI in one switch.
* Pure kernel component with zero plugin dependencies — before settled it may
* only rely on itself (the fail-loud presentation must not depend on the
* system whose failure it reports; the status/signal stores are kernel-own,
* web2 shell self-sufficiency rule); the real UI is produced by the
* app-shell entry once every entry is active. A failed boot keeps the
* loading page, lists the per-entry fiber states and the sweep report (fail
* loud, no partial UI).
*/
import { useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
import css from './AppRoot.module.css'
/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
export interface AppRootProps {
/** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
settled: ObservableSnapshot<boolean>
/** Loader per-plugin status store (drives loading/failed rendering). */
status: SnapshotStore<LoaderStatus>
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
settled: KernelSignal<boolean>
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
status: KernelSignal<LoaderStatus>
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
error: KernelSignal<string | undefined>
/** Builds the real UI; called only after settled. */
renderApp: () => ReactNode
}
/** Boot gate: loading page until the loader settles; failures stay here. */
/** Boot gate: loading page until the boot settles; failures stay here. */
export function AppRoot(props: AppRootProps) {
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
if (settled) return <>{props.renderApp()}</>
const loud = error !== undefined || failed.length > 0
return (
<div className={css.boot}>
<div className={css.card}>
<div className={css.wordmark}>HARNESS</div>
{failed.length === 0
{!loud
? (
<>
<div className={css.spinner} />
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
<div className={css.failed}>
<div className={css.failedTitle}>Failed to load plugins</div>
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
{error !== undefined && <div className={css.failedItem}>{error}</div>}
</div>
)}
</div>

View File

@@ -0,0 +1,59 @@
/**
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
* responsibility, packaged as a normal static-arrival entry so the host graph
* stays the single composition authority. It rides the same entry lifecycle
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
* time apply runs the layout entry is mounted and its export surface is
* readable from the governance side (module loadCache, design §2.6).
*
* The pseudo package id exists only in the host graph and the shell's static
* registry; there is no npm package behind it.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { buildRenderApp } from './app.tsx'
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
/** The assembled-UI face AppRoot renders once the boot settles. */
export interface AppShellService {
/** Build (once) and render the real UI tree. */
renderApp: () => ReactNode
}
declare module 'cordis' {
interface Context {
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
appShell: AppShellService
}
}
/** Cordis plugin name. */
export const name = 'app-shell'
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
export const inject = ['slots', 'sessions', 'layout']
/**
* Plugin body: install the React renderer into the slot system and provide
* the renderApp face (one ctx-level renderSlot('root') call).
* @param ctx - plugin context (inject set active).
*/
export function apply(ctx: Context): void {
// The renderer install is shell territory (web-react is shell-bundled),
// but ctx.slots exists only once the runtime entry is active — so it lands
// here, on the entry whose inject set guarantees that ordering.
ctx.slots.install(createSlotRenderer())
// Assemble once on first render: the closure must be identity-stable
// across AppRoot re-renders.
let renderApp: (() => ReactNode) | undefined
ctx.reflect.provide('appShell', {
renderApp: (): ReactNode => {
renderApp ??= buildRenderApp({ ctx })
return renderApp()
},
})
}

View File

@@ -1,8 +1,9 @@
/**
* Real-UI assembly closure. Runs only after loader.settled(): the whole
* layout tree hangs off the built-in 'root' slot (ui-layout registers
* AppFrame there and renders the child slots internally) — the shell's
* render is the one ctx-level renderSlot call in the program.
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
* set is active: the whole layout tree hangs off the built-in 'root' slot
* (ui-layout registers AppFrame there and renders the child slots
* internally) — the shell's render is the one ctx-level renderSlot call in
* the program.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
import type {} from '@deepseek-ai/dsh-client-runtime/client'
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
export interface AssemblyDeps {
/** Client root context (all plugin services provided). */
/** Client context with the assembly's inject set active. */
ctx: Context
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
requireModule: (spec: string) => unknown
}
/**
* Build the renderApp factory handed to AppRoot.
* Build the renderApp factory the app-shell plugin provides to AppRoot.
* @param deps - assembly inputs.
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
*/

View File

@@ -1,73 +1,172 @@
/**
* Web shell boot — the library face consumed by the apps/web entry (api
* contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
* imported; the loader cannot load itself) → seed the module table → render
* the AppRoot loading page → loader.start() → await settled() → flip the
* settled signal so AppRoot switches to the real UI in one pass. Load
* failures reject settled(); AppRoot stays on the loading page listing them
* (fail loud).
* Web shell boot — the kernel face consumed by the apps/web entry. Everything
* here is machinery that cannot itself be an entry, and none of it
* value-imports a plugin package (web2 shell self-sufficiency rule: the
* loading page must work while — especially when — plugins fail).
*
* Two-stage boot (web2 §0):
* Stage one (module face): build the module system over the host graph
* (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
* — fetch + execute registers factories only; module side effects wait for
* materialization. Prefetch failures are non-fatal here: stage two's
* import path retries the fetch and owns the loud failure.
* Stage two (plugin face): mount the vendored cordis Loader, inject the
* module system as its internal seam (BEFORE any entry exists — the
* bare-import fallback in tree.import must never run in a browser), create
* one loader entry per graph row (tree.import materializes each module),
* let fibers activate on service availability, then loader.await() + a
* full fiber sweep (all ACTIVE, else reject listing who/what/which
* service) → flip the settled signal so AppRoot switches to the real UI in
* one pass.
*
* Composition lives in the host graph; the shell makes zero composition
* decisions (the app-shell assembly is itself a graph entry, the only
* shell-own module registered with the module system).
*/
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { createRoot } from 'react-dom/client'
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
import {
createClientModuleLoader,
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
} from '@deepseek-ai/dsh-client-modules'
import * as AppShell from './app-shell.ts'
import { APP_SHELL_ID } from './app-shell.ts'
import { AppRoot } from './AppRoot.tsx'
import { buildRenderApp } from './app.tsx'
import { seedModules } from './seed.ts'
import { getStaticModules } from './seed.ts'
import {
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
} from './loader-status.ts'
import './base.css'
/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
let value = false
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
flip: () => {
value = true
for (const fn of [...listeners]) fn()
},
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
/**
* Sweep every loader entry after the tree quiesced: an entry without a fiber
* failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
* (a required service never arrived — cordis inject waiting has no timeout,
* so this sweep is the fail-loud compensation).
*/
function assertEntriesActive(ctx: Context): void {
const failures: string[] = []
for (const entry of ctx.loader.entries()) {
const name = entry.options.name
if (entry.fiber === undefined) {
failures.push(`${name}: import failed (see console for the import error)`)
continue
}
const state = STATE_LABELS[entry.fiber.state]
if (state === 'active') continue
if (state === 'pending') {
const missing = Object.keys(entry.fiber.inject).filter((service) => ctx.get(service) === undefined)
failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${name}: ${state}`)
}
}
if (failures.length > 0) {
throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
await Promise.all(graph.entries
.filter((row) => row.immediately === true)
.map((row) => modules.prefetch(row.id).catch(() => {
// Import (stage two) refetches and reports this loudly per entry;
// swallowing here keeps one failing prefetch from masking the others.
})))
}
/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
async function runPluginBoot(
ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
): Promise<void> {
await ctx.plugin(Loader)
const loader = ctx.loader
// Inject the module system BEFORE any entry exists: tree.import falls back
// to a bare dynamic import when internal is undefined, which in a browser
// is a guaranteed loud failure — correct as a tripwire, never as a path.
loader.internal = modules as never
// Status projection: AppRoot displays fiber truth. Every internal/status
// transition under an entry re-projects that entry's row from its ROOT
// fiber (child plugin fibers share the same entry).
ctx.on('internal/status', (fiber) => {
const entry = fiber.entry
if (entry === undefined || entry.fiber === undefined) return
status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
})
// Entry creation order carries no semantics (fiber inject waiting owns
// activation order); creating concurrently lets non-prefetched bundle
// fetches parallelize. The app-shell assembly entry is appended by the
// kernel: it is shell-own code (host graph rows are all plugin bundles),
// and mounting the assembly is not a composition decision — it rides the
// same entry lifecycle so the sweep and status cover it uniformly.
const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
await Promise.all(rows.map(async (name) => {
status.set(name, 'loading')
const id = await loader.create({ name })
// A failed import leaves the entry fiberless (Entry._init logs and
// returns); project it as failed — no fiber means no status event.
if (loader.resolve(id).fiber === undefined) {
status.set(name, 'failed')
}
}))
await loader.await()
assertEntriesActive(ctx)
}
/**
* Mount the web shell into a DOM element and start the plugin load chain.
* Mount the web shell into a DOM element and start the two-stage boot chain.
* @param el - mount point (the app's #root).
* @param seams - optional loader transport overrides (test environments).
* @param seams - optional module transport overrides (test environments).
* @returns unmount disposer.
*/
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
const ctx = new Context()
const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
ctx.reflect.provide('loader', loader)
const graph = (globalThis as DshWindow).__DSH_BOOT__
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
const settled = settledSignal()
// Assemble once on first post-settled render: SessionProvider and the slot
// closures must be identity-stable across re-renders.
let renderApp: (() => ReactNode) | undefined
const renderAppOnce = (): ReactNode => {
renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
return renderApp()
}
const ctx = new Context()
const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
// The app-shell assembly is the only shell-own module: every other graph
// row is a plugin bundle arriving through fetch (web2 single package form).
modules.registerStatic(APP_SHELL_ID, AppShell)
// Contract C5: the module system is a boot-owned kernel service (ctx.modules).
ctx.reflect.provide('modules', modules)
const status = createLoaderStatusStore()
const settled = createSignal(false)
const error = createSignal<string | undefined>(undefined)
const root = createRoot(el)
root.render(<AppRoot settled={settled} status={loader.status} renderApp={renderAppOnce} />)
loader.start()
loader.settled().then(
() => {
// The renderer install is a shell-boot act, but ctx.slots exists only
// once the runtime plugin loaded — so it lands here, after settled and
// before the flip that lets renderApp call renderSlot('root').
ctx.slots.install(createSlotRenderer())
settled.flip()
},
() => { /* stay on the loading page; failures render from loader.status */ },
root.render(
<AppRoot
settled={settled}
status={status}
error={error}
renderApp={() => {
const shell = ctx.get('appShell')
// Unreachable after a clean settle (the app-shell entry is in every graph).
if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
return shell.renderApp()
}}
/>,
)
prefetchImmediateTier(modules, graph)
.then(() => runPluginBoot(ctx, modules, graph, status))
.then(
() => { settled.set(true) },
(reason: unknown) => {
// Stay on the loading page; surface the sweep report (fail loud).
console.error(reason)
error.set(reason instanceof Error ? reason.message : String(reason))
},
)
return () => { root.unmount() }
}

View File

@@ -1,12 +1,20 @@
/**
* Web shell library entry. The shell's product is {@link bootWebShell} —
* apps/web's vite entry calls it against #root; everything else (AppRoot
* gate, assembly closure, module-table seed) is internal to the boot chain.
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
* single source of truth for the tsdown client externals projection.
* @module @deepseek-ai/dsh-client-web
*/
export { bootWebShell } from './boot.tsx'
export { bootWebShell, type BootSeams } from './boot.tsx'
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
export { seedModules } from './seed.ts'
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
export { getStaticModules } from './seed.ts'
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
export {
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
} from './loader-status.ts'

View File

@@ -0,0 +1,111 @@
/**
* Fiber-state projection vocabulary and the kernel-owned status store for the
* boot loading page. The status AppRoot renders is a projection of the real
* cordis fiber states (display the truth, not a retelling) — the boot chain
* subscribes `internal/status` and recomputes one row per loader entry.
*
* The store is hand-rolled here because of the shell self-sufficiency rule
* (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
* package, and the shell kernel must not value-import any plugin package —
* the loading page has to work while (and especially when) plugins fail.
* @module @deepseek-ai/dsh-client-web/src/loader-status
*/
import type { FiberState } from 'cordis'
/**
* Value mirror of cordis's `FiberState` const enum: a const enum has no
* runtime object to import (and esbuild-based pipelines cannot inline it
* across modules), so these values mirror the pinned vendored definition
* while retaining its type (same rationale as dsh-tool-cordis's mirror).
*/
export const FIBER_STATE = {
PENDING: 0 as FiberState.PENDING,
LOADING: 1 as FiberState.LOADING,
ACTIVE: 2 as FiberState.ACTIVE,
FAILED: 3 as FiberState.FAILED,
DISPOSED: 4 as FiberState.DISPOSED,
UNLOADING: 5 as FiberState.UNLOADING,
} as const
/** One entry's projected state label (lower-case face of {@link FiberState}). */
export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
[FIBER_STATE.PENDING]: 'pending',
[FIBER_STATE.LOADING]: 'loading',
[FIBER_STATE.ACTIVE]: 'active',
[FIBER_STATE.FAILED]: 'failed',
[FIBER_STATE.DISPOSED]: 'disposed',
[FIBER_STATE.UNLOADING]: 'unloading',
}
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
export type LoaderStatus = Record<string, LoaderEntryState>
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
export interface KernelSignal<T> {
/** Current value (stable reference between changes). */
getSnapshot(): T
/**
* Subscribe to changes.
* @param fn - change listener.
* @returns the unsubscribe disposer.
*/
subscribe(fn: () => void): () => void
}
/** Writable one-value signal (settled flag, boot failure report). */
export interface KernelValueSignal<T> extends KernelSignal<T> {
/**
* Publish a new value and notify subscribers.
* @param next - the new value.
*/
set(next: T): void
}
/**
* Create a writable kernel signal.
* @param init - initial value.
* @returns the signal.
*/
export function createSignal<T>(init: T): KernelValueSignal<T> {
let value = init
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
set: (next) => {
value = next
for (const fn of [...listeners]) fn()
},
}
}
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
/**
* Project one entry's state (copy-on-write so getSnapshot references only
* change on writes — useSyncExternalStore contract).
* @param id - entry name.
* @param state - projected fiber state.
*/
set(id: string, state: LoaderEntryState): void
}
/**
* Create the boot status store.
* @returns the store (empty until the boot chain projects rows).
*/
export function createLoaderStatusStore(): LoaderStatusStore {
let value: LoaderStatus = {}
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
set: (id, state) => {
value = { ...value, [id]: state }
for (const fn of [...listeners]) fn()
},
}
}

View File

@@ -0,0 +1,20 @@
/**
* Platform singletons the shell shares into the module table.
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
* client externals = the shared surface. The three projections import this
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
* external judgement (packages/client/tsdown.client.ts), and the vite alias
* check — so the list cannot drift between them.
* @module @deepseek-ai/dsh-client-web/src/platform
*/
/** The module specifiers the shell shares into the frozen module table. */
export const PLATFORM_MODULES = [
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
] as const
/** One platform module specifier (a seed-table key). */
export type PlatformModule = (typeof PLATFORM_MODULES)[number]

View File

@@ -1,10 +1,10 @@
/**
* Pure-library module-table seed. These are the ONLY entities statically
* built into the shell bundle besides the loader machinery — every plugin
* (including the infrastructure four) arrives as a dynamic bundle and
* resolves its externals against this table through the loader's require.
* Keys must match the tsdown client preset's external specifiers
* (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
* Platform-singleton module-table. These are the ONLY entities the shell
* shares into the frozen module table — fetch bundles resolve their externals
* against exactly this set through the loader's require. Keys come from the
* platform constant module ({@link ./platform.ts}, contract C1: single source
* of truth with the tsdown client externals); values stay shell-static
* imports so every bundle sees the same instance.
*/
import * as React from 'react'
import * as ReactJsxRuntime from 'react/jsx-runtime'
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import type { PlatformModule } from './platform.ts'
/**
* Build the seed table handed to the loader machinery at boot.
* @returns module specifier → export-surface entity.
* Build the static table handed to the module loader at boot.
* @returns module specifier → export-surface entity (one entry per platform word).
*/
export function seedModules(): Record<string, unknown> {
export function getStaticModules(): Record<string, unknown> {
// The satisfies pin is the projection contract: a word added to
// PLATFORM_MODULES without a static import here (or vice versa) fails to
// compile instead of drifting into a runtime require miss.
return {
'react': React,
'react/jsx-runtime': ReactJsxRuntime,
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
'@deepseek-ai/dsh-client-web-react': WebReact,
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
}
} satisfies Record<PlatformModule, unknown>
}

View File

@@ -1,42 +1,33 @@
// @vitest-environment jsdom
/**
* AppRoot boot-gate smoke: loading page until the settled signal flips (status
* alone never opens the gate), fail-loud plugin list, one-pass switch to the
* real UI. The full browser chain (real loader + bundles) is the e2e's job;
* this pins the shell-owned gate semantics.
* alone never opens the gate), fail-loud entry list + boot failure report,
* one-pass switch to the real UI. The full browser chain (real module system
* + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
* gate semantics. Stores are the kernel-own signals production boot uses
* (shell self-sufficiency: the loading page depends on no plugin package).
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
afterEach(cleanup)
// The snapshot-store engine lives with runtime now; the status-store stub
// uses the same channel production code does.
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
function signal(): ObservableSnapshot<boolean> & { flip: () => void } {
let value = false
const listeners = new Set<() => void>()
return {
getSnapshot: () => value,
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
flip: () => { value = true; for (const fn of [...listeners]) fn() },
}
}
import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
function mount() {
const settled = signal()
const status = createSnapshotStore<LoaderStatus>({})
const settled = createSignal(false)
const error = createSignal<string | undefined>(undefined)
const status = createLoaderStatusStore()
let renders = 0
const utils = render(
<AppRoot
settled={settled}
status={status}
error={error}
renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
/>,
)
return { settled, status, counts: () => renders, ...utils }
return { settled, status, error, counts: () => renders, ...utils }
}
describe('AppRoot', () => {
@@ -50,24 +41,34 @@ describe('AppRoot', () => {
it('all-active status alone does not open the gate (settled signal is the only key)', () => {
const { status, queryByTestId } = mount()
act(() => {
status.update((d) => { d['a'] = 'active'; d['b'] = 'active' })
status.set('a', 'active')
status.set('b', 'active')
})
expect(queryByTestId('real-ui')).toBeNull()
})
it('lists failed plugins and stays on the loading page', () => {
it('lists failed entries and stays on the loading page', () => {
const { status, getByText, queryByTestId } = mount()
act(() => {
status.update((d) => { d['@deepseek-ai/dsh-client-ui-theme'] = 'failed'; d['ok'] = 'active' })
status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
status.set('ok', 'active')
})
expect(getByText('Failed to load plugins')).toBeTruthy()
expect(getByText('@deepseek-ai/dsh-client-ui-theme')).toBeTruthy()
expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
expect(queryByTestId('real-ui')).toBeNull()
})
it('renders the boot failure report even when no entry projected failed', () => {
const { error, getByText, queryByTestId } = mount()
act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
expect(getByText('Failed to load plugins')).toBeTruthy()
expect(getByText(/waiting for service/)).toBeTruthy()
expect(queryByTestId('real-ui')).toBeNull()
})
it('flipping settled switches to the real UI in one pass', () => {
const { settled, getByTestId, queryByText, counts } = mount()
act(() => { settled.flip() })
act(() => { settled.set(true) })
expect(getByTestId('real-ui')).toBeTruthy()
expect(queryByText('HARNESS')).toBeNull()
expect(counts()).toBe(1)

View File

@@ -1,233 +0,0 @@
// @vitest-environment jsdom
/**
* bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
* the loader's <script> execute path runs for real): fetch is stubbed to
* serve fake bundle text, everything else is production code — seeded module
* table, DSHClientProxy handoff, inject topology, renderer install after
* settled, the one-line renderSlot('root') shell, and the fail-loud paths —
* through the loader's fetch/execute seams (jsdom's <script> vm context
* cannot reach the test window, so execute is indirect eval). The fake
* runtime is the REAL SlotsService mounted by the real runtime plugin shape;
* full-fidelity plugin content belongs to the apps/web e2e.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act } from '@testing-library/react'
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
interface BootWindow extends Window {
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
DSHClientProxy?: unknown
__TEST_SLOTS_SERVICE__?: unknown
__TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
}
const win = window as unknown as BootWindow
/**
* Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
* install/renderSlot) plus a minimal sessions face for the renderer host.
* The runtime package is not a seeded library (in production it arrives as a
* bundle), so the spec hands the real class in through a window global — the
* plugin body and everything downstream stay production code.
*/
const RUNTIME_STUB = `
window.DSHClientProxy.loadPlugin({
id: 'fake-runtime',
factory: (require) => {
const SlotsService = window.__TEST_SLOTS_SERVICE__
const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
return {
apply: (ctx) => {
ctx.plugin(SlotsService)
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
ctx.provide('sessions', {
list,
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
})
},
}
},
})`
/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
* child, seat a store factory, expose the store round trip as a probe. */
const LAYOUT_STUB = `
window.DSHClientProxy.loadPlugin({
id: 'fake-layout',
factory: (require) => {
const React = require('react')
const { defineStore } = window.__TEST_RUNTIME_STORE__
return {
inject: ['slots'],
apply: (ctx) => {
const createProbeStore = () => defineStore({
init: () => ({ sidebar: 300, details: 360 }),
actions: {
setSidebar: (d, px) => { d.sidebar = px },
setDetails: (d, px) => { d.details = px },
},
})
ctx.slots.register({
name: 'root',
children: { 'probe.child': { kind: 'single', scope: 'root' } },
store: createProbeStore,
}, (props) => {
const sw = props.useStore((st) => st.sidebar)
const dw = props.useStore((st) => st.details)
return React.createElement('div', {
'data-testid': 'fake-frame',
'data-widths': sw + 'x' + dw,
onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
}, props.renderSlot('probe.child', {}))
})
},
}
},
})`
// The shell assembly requires the layout surface under its production id.
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
/** Loader seams: serve fake bundle text and execute it via indirect eval (jsdom's <script> vm context cannot see the test window). */
function seams(bundles: Record<string, string>) {
return {
fetchBundle: (url: string): Promise<string> => {
const hit = Object.keys(bundles).find((b) => url.endsWith(b))
if (hit === undefined) return Promise.reject(new Error(`bundle fetch ${url} answered 404`))
return Promise.resolve(bundles[hit]!)
},
executeBundle: (code: string): void => {
(0, eval)(code)
},
}
}
function mountPoint(): HTMLElement {
const el = document.createElement('div')
document.body.appendChild(el)
return el
}
async function flushLoader(): Promise<void> {
// fetch + per-plugin apply chain across macrotask turns; a few settle it.
for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
}
function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
return [
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
]
}
function fakeBundles(): Record<string, string> {
return {
'/plugins/fake-runtime.js': RUNTIME_STUB,
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
}
}
afterEach(() => {
delete win.__DSH_BOOT__
delete win.DSHClientProxy
delete win.__TEST_SLOTS_SERVICE__
delete win.__TEST_RUNTIME_STORE__
document.body.innerHTML = ''
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
document.title = ''
})
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
function seedSlotsService(): void {
win.__TEST_SLOTS_SERVICE__ = SlotsService
win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
}
describe('bootWebShell (real loader + real script execution)', () => {
it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
win.__DSH_BOOT__ = { plugins: bootPlugins() }
seedSlotsService()
const el = mountPoint()
document.title = 'DeepSeek Harness'
let unmount: (() => void) | undefined
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
expect(el.textContent).toContain('HARNESS')
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
await flushLoader()
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
expect(el.textContent).not.toContain('HARNESS')
expect(document.title).toBe('S1 — DeepSeek Harness')
act(() => { unmount!() })
expect(el.childElementCount).toBe(0)
expect(document.title).toBe('DeepSeek Harness')
})
it('store seat round-trips through the entry props (useStore + actions)', async () => {
win.__DSH_BOOT__ = { plugins: bootPlugins() }
seedSlotsService()
const el = mountPoint()
act(() => { bootWebShell(el, seams(fakeBundles())) })
await flushLoader()
const frame = el.querySelector('[data-testid="fake-frame"]')
expect(frame).not.toBeNull()
// Width write/read round trip through the framework-delivered store share.
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
act(() => { (frame as HTMLElement).click() })
expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
})
it('fail loud: a 404 bundle keeps the loading page and lists the plugin id', async () => {
win.__DSH_BOOT__ = { plugins: [{ id: 'absent-plugin', url: '/plugins/absent.js', inject: [] }] }
const el = mountPoint()
act(() => { bootWebShell(el, seams({})) })
await flushLoader()
expect(el.textContent).toContain('Failed to load plugins')
expect(el.textContent).toContain('absent-plugin')
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
})
it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
// Runtime loads (slots service present, renderer installed) but no layout
// entry ever registers into 'root' — the ctx-level renderSlot must throw.
win.__DSH_BOOT__ = {
plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
}
seedSlotsService()
const el = mountPoint()
// React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
const consoleError = console.error
console.error = () => {}
try {
act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
let thrown: unknown
try {
await flushLoader()
} catch (error) {
thrown = error
}
expect(String(thrown)).toMatch(/'root' has no registration/)
} finally {
console.error = consoleError
}
})
})
describe('buildRenderApp — assembly contract', () => {
it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
const { Context } = await import('cordis')
const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
const ctx = new Context()
const fiber = ctx.plugin(SlotsService)
await fiber.await()
ctx.provide('sessions', {
list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
})
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
expect(renderApp).toBeTypeOf('function')
// No renderer installed: the one-line shell must surface the boot-order error.
expect(() => renderApp()).toThrow(/renderer not installed/)
})
})

View File

@@ -11,6 +11,12 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../modules"
},
{
"path": "../ui-slots"
},
@@ -20,18 +26,9 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-theme"
},
{
"path": "../ui-layout"
},
{
"path": "../../support/invariants"
}