فهرست منبع

refactor(client): share boot and transport integration points

imccyu 2 هفته پیش
والد
کامیت
48d83eca66

+ 15 - 2
packages/api/gateway/src/client/index.ts

@@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract<RemoteResult<never>, { readonly ok
   return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
 }
 
-function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
+/**
+ * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and
+ * the thrown message. Exported so a stand-in for this face folds identically.
+ * @param endpoint - `<namespace>/<method>` that was called.
+ * @param error - what the carrier threw.
+ * @returns the failed result.
+ */
+export function carrierFailure(endpoint: string, error: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
   return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
 }
 
-function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
+/**
+ * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`.
+ * @param endpoint - `<namespace>/<method>` that was called.
+ * @param cause - what the carrier threw when the signal aborted.
+ * @returns the failed result.
+ */
+export function cancelledFailure(endpoint: string, cause: unknown): Extract<RemoteResult<never>, { readonly ok: false }> {
   return {
     ok: false,
     error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }),

+ 2 - 1
packages/api/session-controller/package.json

@@ -47,7 +47,8 @@
         "@deepseek-ai/dsh-api-gateway/client"
       ],
       "inject": [
-        "@deepseek-ai/dsh-api-gateway"
+        "@deepseek-ai/dsh-api-gateway",
+        "@deepseek-ai/dsh-client-file-upload"
       ],
       "platform": "web"
     }

+ 10 - 4
packages/client/connection/src/client/index.ts

@@ -78,9 +78,15 @@ export const inject: string[] = []
  * provides both halves here instead of forking this plugin.
  */
 export interface ClientTransportHooks {
-  /** Transport for generic unary RPC channels (the Typert gateway). */
-  fetch: RpcFetch
-  /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */
+  /**
+   * Already decoded logical RPC carrier. When present it replaces the HTTP
+   * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process
+   * Host such as a test mock plugs in here).
+   */
+  rpc?: ClientConnectionRpc
+  /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */
+  fetch?: RpcFetch
+  /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */
   openStream?: RpcStreamOpen
   /**
    * Bundle transport for the module system, present when the carrier also owns
@@ -185,7 +191,7 @@ export function apply(ctx: Context): void {
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
   const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__)
-  const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
+  const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
   let generationId = 0

+ 17 - 1
packages/client/connection/tests/client-apply.client.spec.ts

@@ -6,8 +6,10 @@ import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
   apply,
+  type ClientConnectionRpc,
   type ClientTransportHooks,
   type ConnectionGenerationSource,
+  type RpcFetch,
   type ConnectionHandle,
   type ConnectionState,
 } from '../src/client/index.ts'
@@ -483,6 +485,20 @@ describe('connection client apply', () => {
     })
   })
 
+  it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => {
+    ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
+    const rpc: ClientConnectionRpc = {
+      call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })),
+      open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()),
+    }
+    ;(globalThis as Win).__DSH_TRANSPORT__ = { rpc }
+    const handle = await mount()
+    expect(handle.rpc).toBe(rpc)
+    await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({
+      ok: true, value: { endpoint: 'session/list', payload: { args: [] } },
+    })
+  })
+
   it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
     ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
     const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
@@ -492,7 +508,7 @@ describe('connection client apply', () => {
       })(),
     )
     ;(globalThis as Win).__DSH_TRANSPORT__ = {
-      fetch: vi.fn<ClientTransportHooks['fetch']>(),
+      fetch: vi.fn<RpcFetch>(),
       openStream,
       ownsHost: true,
     }

+ 3 - 1
packages/client/hmr/package.json

@@ -31,7 +31,9 @@
   },
   "dsh": {
     "client": {
-      "inject": [],
+      "inject": [
+        "@deepseek-ai/dsh-client-modules"
+      ],
       "platform": "web",
       "immediately": true
     }

+ 19 - 12
packages/client/hmr/src/client/index.ts

@@ -75,6 +75,24 @@ export const name = 'client-hmr'
 /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */
 export const inject = ['loader', 'modules']
 
+/**
+ * Registry-first teardown of an entry's running fiber so `entry.refresh()`
+ * rebuilds it (see the module comment): delete the runtime record before the
+ * fiber's disposer emits `internal/plugin` (or the Loader flags the entry
+ * disabled), drain the unload so effect disposers finish before a new apply
+ * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of
+ * no-oping. A fiberless entry is left untouched.
+ * @param entry - the Loader entry to tear down.
+ */
+export async function tearDownEntryFiber(entry: Entry): Promise<void> {
+  const oldFiber = entry.fiber
+  if (oldFiber === undefined) return
+  const runtime = oldFiber.runtime
+  if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
+  while (oldFiber.inertia !== undefined) await oldFiber.inertia
+  delete entry.fiber
+}
+
 /** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */
 function findEntry(loader: Loader, id: string): Entry | undefined {
   for (const entry of loader.entries()) {
@@ -115,18 +133,7 @@ export function apply(ctx: Context): void {
     modLoader.invalidate(id, rev)
     await modLoader.prefetch(id)
 
-    const oldFiber = entry.fiber
-    if (oldFiber !== undefined) {
-      // Registry-first teardown (see module comment): the runtime record must
-      // be gone before the fiber's disposer emits internal/plugin, or the
-      // Loader flags the entry disabled.
-      const runtime = oldFiber.runtime
-      if (runtime !== null) entry.ctx.registry.delete(runtime.callback)
-      // Drain the unload: effect disposers (slots, subscriptions) must finish
-      // before the new bundle executes and the new apply re-registers.
-      while (oldFiber.inertia !== undefined) await oldFiber.inertia
-      delete entry.fiber
-    }
+    await tearDownEntryFiber(entry)
     // Old owned styles go before materialization re-injects them (the CSS
     // idempotency guard keys on stable tag ids).
     removeOwnedStyles(id)

+ 1 - 1
packages/client/modules/src/client/index.ts

@@ -18,7 +18,7 @@ import type {
 } from './manifest.ts'
 
 export { ClientModuleSystem }
-export { parseBootManifest, stripClientSuffix } from './manifest.ts'
+export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts'
 export type {
   BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration,
   ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord,

+ 46 - 0
packages/client/modules/src/client/manifest.ts

@@ -30,6 +30,7 @@
  */
 
 import type {} from '@deepseek-ai/cordis'
+import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
 import type { ClientModuleSystem } from './system.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -144,6 +145,51 @@ export function optionalStringArray(subject: string, field: string, value: unkno
   return value as string[]
 }
 
+/**
+ * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared
+ * by the node half's Loader scan and the roster generator, so both read a
+ * package's browser declaration through one validator.
+ * @param pkgName - package name used as the diagnostic prefix.
+ * @param value - the raw `dsh.client` field of the package manifest.
+ * @returns the validated declaration, or undefined when the field is absent.
+ * @throws {Error} when the field is present but any member is malformed.
+ */
+export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
+  if (value === undefined) return undefined
+  if (typeof value !== 'object' || value === null) {
+    throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
+  }
+  const decl = value as Record<string, unknown>
+  if (typeof decl.platform !== 'string') {
+    throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
+  }
+  const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
+  const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
+  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
+    throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
+  }
+  return {
+    platform: decl.platform,
+    ...(inject !== undefined ? { inject } : {}),
+    ...(external !== undefined ? { external } : {}),
+    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
+  }
+}
+
+/**
+ * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified
+ * specifier (`cordis:` builtins, `node:` modules, URLs).
+ * @param specifier - Loader row name.
+ * @returns the package name, or undefined.
+ */
+export function exactPackageSpecifier(specifier: string): string | undefined {
+  if (specifier.startsWith('@')) {
+    const parts = specifier.split('/')
+    return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
+  }
+  return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined
+}
+
 /**
  * Normalize a module specifier onto the graph row that owns it: a plugin bundle
  * IS its package's client half, so `<id>/client` (the exports subpath external

+ 1 - 34
packages/client/modules/src/index.ts

@@ -33,8 +33,7 @@ import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
-import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest'
-import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
+import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
 import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts'
 
 export { stripClientSuffix } from './client/manifest.ts'
@@ -173,38 +172,6 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$
 /** Debugger source name appended to page bundles in the WebWorker image. */
 const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
 
-/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */
-function exactPackageSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('@')) {
-    const parts = specifier.split('/')
-    return parts.length === 2 && parts.every(Boolean) ? specifier : undefined
-  }
-  return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined
-}
-
-/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
-function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined {
-  if (value === undefined) return undefined
-  if (typeof value !== 'object' || value === null) {
-    throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`)
-  }
-  const decl = value as Record<string, unknown>
-  if (typeof decl.platform !== 'string') {
-    throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
-  }
-  const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
-  const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
-  if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
-    throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
-  }
-  return {
-    platform: decl.platform,
-    ...(inject !== undefined ? { inject } : {}),
-    ...(external !== undefined ? { external } : {}),
-    ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
-  }
-}
-
 /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
 function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
   if (typeof exportsField !== 'object' || exportsField === null) return undefined

+ 83 - 0
packages/client/web/src/boot-client.ts

@@ -0,0 +1,83 @@
+/**
+ * Production client composition without the page: mount the Loader over a
+ * module system, create every manifest row, wait for quiescence, and audit
+ * activation. `AppWebEntry` and the whole-client test carrier both call it.
+ * @module @deepseek-ai/dsh-client-web/src/boot-client
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
+import { STATE_LABELS } from './loader-status.ts'
+
+/** Entry state label as the boot page renders it. */
+export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed'
+
+/** Inputs of {@link bootClient}. */
+export interface ClientBootOptions {
+  /** Fresh root Context that will own the plugin tree. */
+  readonly ctx: Context
+  /** Module system installed as `loader.internal`. */
+  readonly modules: ClientModuleLoader
+  /** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */
+  readonly manifest: BootManifest
+  /** Per-entry state reporting (the boot page); omitted when no one renders progress. */
+  readonly onEntryState?: (name: string, state: EntryStateLabel) => void
+}
+
+/**
+ * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
+ * `loader.create({ name })` per manifest row, `loader.await()`, then
+ * {@link assertEntriesActive}. A row whose module cannot be imported rejects
+ * `loader.create`, so that import error propagates from here as-is.
+ * @param options - context, module system, manifest, optional progress sink.
+ * @returns resolves after every entry is active; rejects with the audit report otherwise.
+ */
+export async function bootClient(options: ClientBootOptions): Promise<void> {
+  const { ctx, manifest, onEntryState } = options
+  await ctx.plugin(Loader)
+  const loader = ctx.loader
+  loader.internal = options.modules as never
+
+  ctx.on('internal/status', (fiber) => {
+    const entry = fiber.entry
+    if (entry === undefined || entry.fiber === undefined) return
+    onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state])
+  })
+
+  const rows = manifest.plugins.map(row => row.id)
+  await Promise.all(rows.map(async (name) => {
+    onEntryState?.(name, 'loading')
+    const id = await loader.create({ name })
+    if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
+  }))
+
+  await loader.await()
+  assertEntriesActive(ctx)
+}
+
+/**
+ * Reject entries that failed import/apply or still wait on missing services.
+ * @param ctx - root Context carrying the Loader.
+ * @throws {Error} listing every non-active entry with its reason.
+ */
+export 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')}`)
+  }
+}

+ 13 - 62
packages/client/web/src/boot.ts

@@ -1,18 +1,18 @@
 /**
  * Web boot kernel. It owns only the module system, Cordis loader, and a
- * framework-free boot page. The dynamic UI renderer receives the mount
+ * framework-free boot page; plugin composition and the renderer handoff are
+ * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount
  * point after every client entry activates.
  * @module @deepseek-ai/dsh-client-web/src/boot
  */
 import { Context } from '@deepseek-ai/cordis'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
 import type {
   BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
 } from '@deepseek-ai/dsh-client-modules/client'
-import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { bootClient } from './boot-client.ts'
 import { BootPage } from './boot-page.ts'
+import { mountClient } from './mount.ts'
 import { getStaticModules } from './seed.ts'
-import { STATE_LABELS } from './loader-status.ts'
 import './base.css'
 
 /** Module transport hook replaced by jsdom tests. */
@@ -76,8 +76,15 @@ export class AppWebEntry {
       const prefetching = this.prefetchImmediateTier()
       const ctx = new Context()
       this.ctx = ctx
-      await this.runPluginBoot(ctx, prefetching)
-      await this.mountApp(ctx)
+      this.page.setTotal(this.manifest.plugins.length)
+      await prefetching
+      await bootClient({
+        ctx,
+        modules: this.modules,
+        manifest: this.manifest,
+        onEntryState: (name, state) => { this.page.setState(name, state) },
+      })
+      await mountClient(ctx, this.container)
     } catch (reason) {
       console.error(reason)
       this.page.fail(reason instanceof Error ? reason.message : String(reason))
@@ -92,14 +99,6 @@ export class AppWebEntry {
     this.page.dispose()
   }
 
-  /** Mount through a dependency fiber so replacing uiRenderer remounts the application. */
-  private async mountApp(ctx: Context): Promise<void> {
-    const mounted = ctx.inject(['uiRenderer'], (scope) => {
-      scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
-    })
-    await mounted
-  }
-
   /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
   private async prefetchImmediateTier(): Promise<void> {
     await Promise.all(this.manifest.plugins
@@ -108,52 +107,4 @@ export class AppWebEntry {
         // Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
       })))
   }
-
-  /** Mount the Loader, create all graph entries, await quiescence, and audit activation. */
-  private async runPluginBoot(ctx: Context, prefetching: Promise<void>): Promise<void> {
-    await ctx.plugin(Loader)
-    const loader = ctx.loader
-    loader.internal = this.modules as never
-
-    ctx.on('internal/status', (fiber) => {
-      const entry = fiber.entry
-      if (entry === undefined || entry.fiber === undefined) return
-      this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
-    })
-
-    const rows = this.manifest.plugins.map(row => row.id)
-    this.page.setTotal(rows.length)
-    await prefetching
-    await Promise.all(rows.map(async (name) => {
-      this.page.setState(name, 'loading')
-      const id = await loader.create({ name })
-      if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed')
-    }))
-
-    await loader.await()
-    this.assertEntriesActive(ctx)
-  }
-
-  /** Reject entries that failed import/apply or still wait on missing services. */
-  private 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')}`)
-    }
-  }
 }

+ 24 - 0
packages/client/web/src/mount.ts

@@ -0,0 +1,24 @@
+/**
+ * Application mount through a dependency fiber, so replacing `uiRenderer`
+ * remounts the application. Shared by `AppWebEntry` and the test carrier.
+ * @module @deepseek-ai/dsh-client-web/src/mount
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+
+/**
+ * Mount the UI renderer into `container` through a dependency fiber on
+ * `uiRenderer`: the mount effect installs when the service is provided and
+ * reinstalls when it is replaced.
+ * @param ctx - booted root Context.
+ * @param container - application mount point.
+ * @returns resolves once the dependency fiber exists; with `uiRenderer`
+ * already provided (as after `bootClient`) the mount effect is installed by
+ * then, otherwise it installs when the service arrives.
+ */
+export async function mountClient(ctx: Context, container: HTMLElement): Promise<void> {
+  const mounted = ctx.inject(['uiRenderer'], (scope) => {
+    scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount')
+  })
+  await mounted
+}

+ 133 - 0
packages/client/web/tests/boot-client.client.spec.ts

@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import {
+  createClientModuleSystem, parseBootManifest,
+  type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph,
+} from '@deepseek-ai/dsh-client-modules/client'
+import { describe, expect, it } from 'vitest'
+import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts'
+import { FIBER_STATE } from '../src/loader-status.ts'
+
+const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules'
+
+function graphOf(ids: readonly string[]): WebBootGraph {
+  const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' }))
+  return {
+    rev: 'graph',
+    entries,
+    batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }],
+  }
+}
+
+/** Module system seeded with inline plugin modules; `loaded` records every transport call. */
+function modulesOf(graph: WebBootGraph, staticModules: Record<string, unknown>): { modules: ClientModuleLoader; loaded: string[] } {
+  const loaded: string[] = []
+  const pendingQueue: ClientBundleRegistration[] = []
+  const target: ClientModuleLoaderTarget = {
+    mode: 'queue',
+    pendingQueue,
+    load: (registration) => { pendingQueue.push(registration) },
+    create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options),
+  }
+  const modules = target.create({
+    boot: graph,
+    staticModules,
+    loadBundle: async (url) => { loaded.push(url) },
+  })
+  return { modules, loaded }
+}
+
+/** Recording progress sink. */
+function stateSink(): { states: Map<string, EntryStateLabel[]>; onEntryState: (name: string, state: EntryStateLabel) => void } {
+  const states = new Map<string, EntryStateLabel[]>()
+  return {
+    states,
+    onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) },
+  }
+}
+
+describe('bootClient', () => {
+  it('activates every seeded row without touching the bundle transport', async () => {
+    const graph = graphOf(['provider', 'consumer'])
+    const { modules, loaded } = modulesOf(graph, {
+      provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } },
+      consumer: { inject: ['x'], apply: () => {} },
+    })
+    const ctx = new Context()
+    const sink = stateSink()
+
+    await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState })
+
+    expect(loaded).toEqual([])
+    const consumer = sink.states.get('consumer') ?? []
+    expect(consumer[0]).toBe('loading')
+    expect(consumer.at(-1)).toBe('active')
+    expect(sink.states.get('provider')?.at(-1)).toBe('active')
+    await ctx.fiber.dispose()
+  })
+
+  it('reports a row waiting on a service the roster never provides', async () => {
+    const graph = graphOf(['orphan'])
+    const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } })
+    const ctx = new Context()
+
+    await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow(
+      'orphan: pending (waiting for service: nothing)',
+    )
+    await ctx.fiber.dispose()
+  })
+
+  it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => {
+    const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } })
+    const manifest = parseBootManifest(graphOf(['ghost']))
+    const ctx = new Context()
+    const sink = stateSink()
+
+    await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow(
+      /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/,
+    )
+    expect(sink.states.get('ghost')).toEqual(['loading'])
+    await ctx.fiber.dispose()
+  })
+})
+
+describe('assertEntriesActive', () => {
+  interface FakeEntry { name: string; fiber?: { state: number; inject: Record<string, null> } }
+
+  /** Loader-shaped double: entries with scripted fiber states, services by name. */
+  function auditCtx(entries: readonly FakeEntry[], services: Record<string, unknown> = {}): Context {
+    return {
+      loader: {
+        * entries() {
+          for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber }
+        },
+      },
+      get: (name: string) => services[name],
+    } as unknown as Context
+  }
+
+  it('passes when every entry is active', () => {
+    expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow()
+  })
+
+  it('names import failures, missing services, and other non-active states', () => {
+    const ctx = auditCtx([
+      { name: 'lost' },
+      { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } },
+      { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } },
+      { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } },
+    ], { present: {} })
+
+    expect(() => { assertEntriesActive(ctx) }).toThrow([
+      'web boot: 4 entries did not activate',
+      'lost: import failed (see console for the import error)',
+      'waiting: pending (waiting for services: a, b)',
+      'opaque: pending (waiting for services: unknown)',
+      'broken: failed',
+    ].join('\n'))
+  })
+
+  it('uses the singular form for one failing entry', () => {
+    expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n')
+  })
+})

+ 47 - 0
packages/client/web/tests/mount.client.spec.ts

@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it, vi } from 'vitest'
+import { mountClient } from '../src/mount.ts'
+
+/** Provide a fake `uiRenderer` from its own plugin fiber so it can be replaced. */
+function provideRenderer(ctx: Context, mount: (container: HTMLElement) => () => void) {
+  return ctx.plugin({ apply: (scope: Context) => { scope.reflect.provide('uiRenderer', { mount }) } })
+}
+
+describe('mountClient', () => {
+  it('mounts into the container and unmounts when the tree is disposed', async () => {
+    const ctx = new Context()
+    const unmount = vi.fn()
+    const mount = vi.fn((_container: HTMLElement) => unmount)
+    provideRenderer(ctx, mount)
+    const container = document.createElement('div')
+
+    await mountClient(ctx, container)
+
+    expect(mount).toHaveBeenCalledExactlyOnceWith(container)
+    expect(unmount).not.toHaveBeenCalled()
+    await ctx.fiber.dispose()
+    expect(unmount).toHaveBeenCalledOnce()
+  })
+
+  it('remounts when uiRenderer is replaced', async () => {
+    const ctx = new Context()
+    const container = document.createElement('div')
+    const first = { unmount: vi.fn(), mount: vi.fn(() => first.unmount) }
+    const second = { unmount: vi.fn(), mount: vi.fn(() => second.unmount) }
+
+    await mountClient(ctx, container)
+    expect(first.mount).not.toHaveBeenCalled()
+
+    const renderer = provideRenderer(ctx, first.mount)
+    await vi.waitFor(() => { expect(first.mount).toHaveBeenCalledExactlyOnceWith(container) })
+
+    await renderer.dispose()
+    expect(first.unmount).toHaveBeenCalledOnce()
+
+    provideRenderer(ctx, second.mount)
+    await vi.waitFor(() => { expect(second.mount).toHaveBeenCalledExactlyOnceWith(container) })
+    await ctx.fiber.dispose()
+    expect(second.unmount).toHaveBeenCalledOnce()
+  })
+})