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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
packages/client/web/src/app-shell.ts
Normal file
59
packages/client/web/src/app-shell.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
111
packages/client/web/src/loader-status.ts
Normal file
111
packages/client/web/src/loader-status.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/client/web/src/platform.ts
Normal file
20
packages/client/web/src/platform.ts
Normal 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]
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user