Преглед изворни кода

refactor(web): collapse the shell boot into the AppWebEntry class

The four free functions in boot.tsx become one kernel class holding what
must exist before cordis: the parsed BootManifest, the ClientModuleSystem
instance, and the loading-page handles. Context/Loader setup runs in
parallel with the immediately-tier prefetch, but entry creation awaits the
prefetch: materialization is tree.import's synchronous require, so
cross-package require edges (i18n -> runtime/client) need every
immediately-tier factory registered first — unbarriered creation raced
10-25% of boots. The kernel adopts the modules entry (writes the
__DSH_MODULES__ slot pre-cordis, creates the entry first, skips its graph
row), and provide('modules') now lives in the adoption apply. apps/web
drops its host-package edges (composition is apps/cli's job).
imccyu пре 2 месеци
родитељ
комит
80cd8f54b5

+ 0 - 1
apps/web/package.json

@@ -24,7 +24,6 @@
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-web-react": "workspace:^",
-    "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@types/node": "^22.0.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 2 - 2
apps/web/src/main.ts

@@ -3,8 +3,8 @@
  * loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
  * in @deepseek-ai/dsh-client-web; this file only finds the mount point.
  */
-import { bootWebShell } from '@deepseek-ai/dsh-client-web'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 const el = document.getElementById('root')
 if (el === null) throw new Error('web app: missing #root')
-bootWebShell(el)
+void new AppWebEntry(el).run()

+ 5 - 3
apps/web/tests/session-title.snapshot.ts

@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
 import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules'
-import { bootWebShell } from '@deepseek-ai/dsh-client-web'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
 
 const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
@@ -81,13 +81,15 @@ it('projects initial and revised durable titles through the built eight-plugin f
   const root = document.querySelector<HTMLElement>('#root')
   if (root === null) throw new Error('snapshot root missing')
   act(() => {
-    unmount = bootWebShell(root, {
+    const entry = new AppWebEntry(root, {
       fetchBundle: (url) => {
         const code = bundles.get(url)
         return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
       },
       executeBundle: (code) => { (0, eval)(code) },
     })
+    void entry.run()
+    unmount = () => { entry.dispose() }
   })
 
   const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })

+ 0 - 291
apps/web/tests/smoke-fixture.e2e.ts

@@ -1,291 +0,0 @@
-// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
-// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
-// chromium. First describe: graph injection + the fail-loud half. Second
-// describe: the settled success pass — all nine REAL tsdown bundles load
-// through the module system + vendored Loader chain in ?fixture mode (the
-// infrastructure four ride the immediately prefetch tier, the UI rows fetch
-// on demand), the three-column frame appears in one flip, and the resident
-// question completes through the real UI stack. The full model round lands
-// in smoke-real under the W5 real-host standard.
-import { existsSync } from 'node:fs'
-import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
-import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
-import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
-import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
-
-const bundlePath = (dir: string): string =>
-  fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
-
-const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
-const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
-
-/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
-const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
-  { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
-  { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
-  { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
-
-const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
-  ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
-
-const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
-  ...(p.inject !== undefined ? { inject: p.inject } : {}),
-  ...(p.immediately === true ? { immediately: true } : {}),
-}))
-
-/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
-const FAIL_GRAPH: WebBootGraph = {
-  rev: 'e2e-fail',
-  entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
-}
-
-/** Graph for the success pass: the complete assembly. */
-const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
-
-/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
-function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
-  return {
-    graph: () => graph,
-    clientPath: (id: string) => byId.get(id),
-    onRebuilt: () => () => undefined,
-  }
-}
-
-describe('web boot chain (keyless, real carrier)', () => {
-  let server: Awaited<ReturnType<typeof startWebServer>>
-  let browser: Browser
-  let page: Page
-  const pageErrors: string[] = []
-
-  beforeAll(async () => {
-    requireDist()
-    const port = await probeFreePort()
-    const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
-    server = await startWebServer({
-      host: '127.0.0.1',
-      port,
-      distIndex: DIST_INDEX,
-      apiHandler,
-      webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
-    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
-    browser = await chromium.launch()
-    page = await browser.newPage()
-    page.on('pageerror', e => pageErrors.push(String(e)))
-    await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
-  })
-
-  afterAll(async () => {
-    await browser?.close()
-    await server?.close()
-  })
-
-  it('GET / injects the entry graph verbatim', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
-    const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
-    expect(boot).toEqual(FAIL_GRAPH)
-  })
-
-  it('serves a real bundle through the plugins endpoint', async () => {
-    const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
-    expect(res.status()).toBe(200)
-    expect(await res.text()).toContain('window.__ModuleLoader__.load')
-  })
-
-  it('boots to the loading page and fail-louds the absent entry', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
-    await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
-    await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
-    await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
-    // The real UI must not have flipped in: the gate opens only on settled.
-    expect(await page.locator('[class*="frame"]').count()).toBe(0)
-  })
-
-  it('applies the token sheets before any plugin CSS', async () => {
-    const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
-    expect(family.trim().length).toBeGreaterThan(0)
-  })
-})
-
-describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
-  let server: Awaited<ReturnType<typeof startWebServer>>
-  let browser: Browser
-  let page: Page
-  const pageErrors: string[] = []
-
-  beforeAll(async () => {
-    requireDist()
-    const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
-    if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
-    const port = await probeFreePort()
-    // ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
-    const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
-    server = await startWebServer({
-      host: '127.0.0.1',
-      port,
-      distIndex: DIST_INDEX,
-      apiHandler,
-      webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
-    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
-    browser = await chromium.launch()
-    page = await browser.newPage()
-    page.on('pageerror', e => pageErrors.push(String(e)))
-    await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
-  })
-
-  afterAll(async () => {
-    await browser?.close()
-    await server?.close()
-  })
-
-  it('settles and flips to the three-column frame in one pass', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
-    await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
-    // Loading page is gone; the grid carries the three tracks.
-    expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
-    const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
-    expect(template.split(' ').length).toBe(3)
-  })
-
-  it('every plugin CSS landed with its ownership tag', async () => {
-    const owners = await page.evaluate(() =>
-      [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
-    expect(owners).toContain(LAYOUT_ID)
-    expect(owners).toContain(SIDEBAR_ID)
-  })
-
-  it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
-    const frame = page.locator('[class*="frame"]')
-    const firstTrack = async (): Promise<string> => (await frame.evaluate(
-      el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
-    // The tracks transition on the deepsuite curve; assert the animated
-    // settle rather than an instant jump.
-    const settledTrack = async (px: string): Promise<void> => {
-      await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
-    }
-    // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
-    const brand = () => page.locator('[class*="brand"]').count()
-    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
-    // Mid-collapse the wide chrome is still mounted, fading — not swapped out.
-    expect(await brand()).toBe(1)
-    await settledTrack('56px')
-    await expect.poll(brand, { timeout: 2000 }).toBe(0)
-    for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
-      await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
-    }
-    await page.getByRole('button', { name: 'Open sidebar' }).click()
-    await settledTrack('280px')
-    await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
-    // Rail search: collapse again, the search control expands and lands in the box.
-    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
-    await settledTrack('56px')
-    await page.getByRole('button', { name: 'Search sessions' }).click()
-    await settledTrack('280px')
-    // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
-    await expect.poll(() => page.evaluate(() =>
-      (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
-  })
-
-  it('renders file tool rows and expands fixture reasoning from either click target', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
-    await page.locator('[role="treeitem"]').first().click()
-    await page.locator('[role="treeitem"][aria-selected]').first().click()
-
-    const thinkRoot = page.locator('[data-variant="think"]').first()
-    const think = thinkRoot.getByRole('button')
-    await think.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await think.getAttribute('aria-expanded')).toBe('false')
-
-    await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
-    expect(await think.getAttribute('aria-expanded')).toBe('true')
-    expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
-
-    await think.getByText('Think', { exact: true }).click()
-    expect(await think.getAttribute('aria-expanded')).toBe('false')
-
-    const editRoot = page.locator('[data-variant="edit"]').first()
-    await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
-    expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
-
-    const writeRoot = page.locator('[data-variant="write"]').first()
-    await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
-    expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
-    expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
-  })
-
-  it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
-    await page.getByRole('button', { name: 'New session', exact: true }).click()
-    const input = page.locator('textarea[placeholder]')
-    await input.waitFor({ timeout: 15_000 })
-    await input.fill('render markdown')
-    await page.getByRole('button', { name: '发送' }).click()
-
-    const streaming = page.locator('[data-streaming="true"]')
-    await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
-    await streaming.waitFor({ state: 'detached', timeout: 15_000 })
-
-    const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
-    expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
-    expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
-    const external = page.getByRole('link', { name: 'DeepSeek' })
-    expect(await external.getAttribute('target')).toBe('_blank')
-    expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
-  })
-
-  it('renders and completes the resident question through the composer slot', async () => {
-    onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
-    const sessionTree = page.getByRole('tree', { name: 'Sessions' })
-    const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
-    if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
-    await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
-    const composer = page.locator('[data-question-key]')
-    await composer.waitFor({ timeout: 15_000 })
-    expect({
-      question: await composer.getByRole('heading').innerText(),
-      progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
-      options: await composer.getByRole('radio').allTextContents(),
-      custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
-    }).toMatchInlineSnapshot(`
-      {
-        "custom": "其他,请填写自定义答案",
-        "options": [
-          "1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
-          "2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
-          "3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
-        ],
-        "progress": "1 / 3",
-        "question": "你现在更想招哪类 Agent/Harness 候选人?",
-      }
-    `)
-
-    await composer.getByRole('radio', { name: '工程落地型' }).click()
-    await composer.getByText('2 / 3', { exact: true }).waitFor()
-    await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
-    await composer.getByRole('checkbox', { name: '系统设计' }).click()
-    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
-    await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
-
-    await composer.waitFor({ state: 'detached' })
-    const restoredInput = page.locator('textarea[placeholder]')
-    await restoredInput.waitFor()
-    expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
-  })
-
-  it('stayed clean: no page errors across the whole load chain', () => {
-    expect(pageErrors).toEqual([])
-  })
-})

+ 1 - 4
apps/web/tests/support.ts

@@ -16,10 +16,7 @@ export function requireDist(): void {
   }
 }
 
-/**
- * OS-assigned free port, released before use. startWebServer echoes
- * options.port instead of the bound one, so passing 0 directly is unusable.
- */
+/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
 export function probeFreePort(): Promise<number> {
   return new Promise((resolvePort, reject) => {
     const probe = createServer()

+ 0 - 3
apps/web/tsconfig.json

@@ -21,9 +21,6 @@
     {
       "path": "../../packages/client/web"
     },
-    {
-      "path": "../../packages/host/webserver"
-    },
     {
       "path": "../../packages/client/modules"
     }

+ 1 - 1
apps/web/vite.config.ts

@@ -22,7 +22,7 @@ export default defineConfig({
       { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
-      { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
     ],
   },
   define: {

+ 1 - 1
packages/client/web/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-client-web
 
-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.
+Web shell kernel: `new AppWebEntry(el, seams?).run()` 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.
 
 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.
 

+ 209 - 143
packages/client/web/src/boot.tsx

@@ -1,23 +1,32 @@
 /**
- * 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
+ * Web shell boot kernel — the face consumed by the apps/web entry. Everything
+ * here is machinery that cannot itself be a loader 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).
+ * loading page must work while — especially when — plugins fail). The one
+ * sanctioned exception is the modules package (design §4.7 bootstrap
+ * identity): the module system cannot arrive through itself, so its class
+ * and its client-half wrapper are shell-bundled and the kernel adopts its
+ * plugin entry once cordis is up.
  *
- * 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.
+ * AppWebEntry.run(), module face first, then plugin face: parse
+ * `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
+ * → build the module system over the module-view rows → render the loading
+ * page → prefetch every `immediately` row in parallel with mounting the
+ * vendored cordis Loader (internal-seam injection BEFORE any entry exists —
+ * the bare-import fallback in tree.import must never run in a browser) →
+ * await the prefetch tier, THEN adopt the modules entry and create one
+ * loader entry per plugin-view row plus the shell-own app-shell assembly
+ * entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
+ * listing who/what/which service) → flip the settled signal so AppRoot
+ * switches to the real UI in one pass.
+ *
+ * Entry creation waits for the whole immediately tier: materialization runs
+ * synchronous cross-package require edges (e.g. i18n → runtime/client) that
+ * fiber inject waiting cannot protect — a bundle's factory must be
+ * registered before any dependent entry materializes. Per-row prefetch
+ * failures still resolve silently (the create-side import refetches and
+ * owns the loud failure), so the barrier never turns one bad bundle into a
+ * boot-wide fail-fast.
  *
  * Composition lives in the host graph; the shell makes zero composition
  * decisions (the app-shell assembly is itself a graph entry, the only
@@ -25,148 +34,205 @@
  */
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
-import { createRoot } from 'react-dom/client'
+import { createRoot, type Root } from 'react-dom/client'
+import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
 import {
-  createClientModuleLoader,
-  type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
-} from '@deepseek-ai/dsh-client-modules'
+  ClientModuleSystem, parseBootManifest,
+  type BootManifest, type ClientModuleSystemOptions, type DshWindow,
+} from '@deepseek-ai/dsh-client-modules/client'
 import * as AppShell from './app-shell.ts'
 import { APP_SHELL_ID } from './app-shell.ts'
 import { AppRoot } from './AppRoot.tsx'
 import { getStaticModules } from './seed.ts'
-import {
-  STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
-} from './loader-status.ts'
+import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
 import './base.css'
 
 /** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
-export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
+export type BootSeams = Pick<ClientModuleSystemOptions, '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).
+ * The modules package's own graph row id. The kernel adopts that entry
+ * itself (its wrapper is statically registered — shell-bundled code, never
+ * fetched), so the plugin-row loop must skip it: the vendored Group.create
+ * does not deduplicate by name, and a second fiber would provide 'modules'
+ * twice.
  */
-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}`)
+const MODULES_ID = '@deepseek-ai/dsh-client-modules'
+
+/**
+ * The web shell kernel: mounts the loading page into a DOM element and runs
+ * the two-stage boot over the host graph. Fields hold only what must exist
+ * before cordis does — the parsed manifest, the module system, and the
+ * loading-page UI handles; everything else lives in plugins.
+ */
+export class AppWebEntry {
+  private readonly el: HTMLElement
+  private readonly seams: BootSeams | undefined
+  private readonly status = createLoaderStatusStore()
+  private readonly settled = createSignal(false)
+  private readonly error = createSignal<string | undefined>(undefined)
+  // Assigned by run() before any private method or settled-gated closure reads them.
+  private ctx!: Context
+  private modules!: ClientModuleSystem
+  private manifest!: BootManifest
+  private root: Root | undefined
+
+  /**
+   * Hold the mount point; all work happens in {@link run}.
+   * @param el - mount point (the app's #root).
+   * @param seams - optional module transport overrides (test environments).
+   */
+  constructor(el: HTMLElement, seams?: BootSeams) {
+    this.el = el
+    this.seams = seams
+  }
+
+  /**
+   * Run the boot chain to settlement. Boot-chain failures resolve (not
+   * reject): the loading page stays up and renders the failure report (the
+   * fail-loud surface the kernel owns). Rejects only when the boot manifest
+   * is missing or malformed — there is nothing to boot against.
+   * @returns resolves once the UI settled or the failure report rendered.
+   */
+  async run(): Promise<void> {
+    this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
+
+    this.modules = new ClientModuleSystem({
+      modules: this.manifest.modules, staticModules: getStaticModules(), ...this.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).
+    this.modules.registerStatic(APP_SHELL_ID, AppShell)
+    // Adoption handoff, supply side (design §4.7): register the modules
+    // package's own client half under its bare package name (= graph row id
+    // = entry name — a suffixed key would miss the statics branch and
+    // trigger a real fetch), and put the instance on the kernel slot the
+    // wrapper's apply reads to provide ctx.modules.
+    this.modules.registerStatic(MODULES_ID, ModulesClient)
+    ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
+
+    this.root = createRoot(this.el)
+    this.root.render(
+      <AppRoot
+        settled={this.settled}
+        status={this.status}
+        error={this.error}
+        renderApp={() => {
+          const shell = this.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()
+        }}
+      />,
+    )
+
+    // The immediately tier prefetches in parallel with Loader mounting;
+    // runPluginBoot awaits it before creating entries (see module comment:
+    // cross-package synchronous require edges need every immediately-tier
+    // factory registered before any materialization).
+    const prefetching = this.prefetchImmediateTier()
+    this.ctx = new Context()
+    try {
+      await this.runPluginBoot(prefetching)
+      this.settled.set(true)
+    } catch (reason) {
+      // Stay on the loading page; surface the sweep report (fail loud).
+      console.error(reason)
+      this.error.set(reason instanceof Error ? reason.message : String(reason))
     }
   }
-  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')}`)
+
+  /** Unmount the shell (loading page or settled UI). */
+  dispose(): void {
+    this.root?.unmount()
   }
-}
 
-/** 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.
-    })))
-}
+  /** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
+  private async prefetchImmediateTier(): Promise<void> {
+    await Promise.all(this.manifest.plugins
+      .filter((row) => row.immediately)
+      .map((row) => this.modules.prefetch(row.id).catch(() => {
+        // Import 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')
-    }
-  }))
+  /** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
+  private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
+    const ctx = this.ctx
+    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 = this.modules as never
 
-  await loader.await()
-  assertEntriesActive(ctx)
-}
+    // 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
+      this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
+    })
 
-/**
- * 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 module transport overrides (test environments).
- * @returns unmount disposer.
- */
-export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
-  const graph = (globalThis as DshWindow).__DSH_BOOT__
-  if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
-
-  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={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() }
+    // Barrier before any entry exists: entry creation materializes bundles,
+    // and materialization runs synchronous cross-package require edges that
+    // need every immediately-tier factory already registered (module
+    // comment). Resolves even when individual prefetches failed.
+    await prefetching
+
+    // Adoption handoff, plugin side: the modules entry is created first —
+    // its wrapper apply reads the kernel slot and provides ctx.modules (the
+    // provide lives on the plugin face; see MODULES_ID for why the row loop
+    // must then skip it).
+    const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
+    // 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.
+    await Promise.all(rows.map(async (name) => {
+      this.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) {
+        this.status.set(name, 'failed')
+      }
+    }))
+
+    await loader.await()
+    this.assertEntriesActive()
+  }
+
+  /**
+   * 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).
+   */
+  private assertEntriesActive(): void {
+    const ctx = this.ctx
+    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')}`)
+    }
+  }
 }

+ 3 - 3
packages/client/web/src/index.ts

@@ -1,13 +1,13 @@
 /**
- * Web shell library entry. The shell's product is {@link bootWebShell} —
- * apps/web's vite entry calls it against #root; everything else (AppRoot
+ * Web shell library entry. The shell's product is {@link AppWebEntry} —
+ * apps/web's vite entry runs it against #root; everything else (AppRoot
  * 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, type BootSeams } from './boot.tsx'
+export { AppWebEntry, 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'