boot.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * Web shell boot kernel — the face consumed by the apps/web entry. Everything
  3. * here is machinery that cannot itself be a loader entry, and none of it
  4. * value-imports a plugin package (web2 shell self-sufficiency rule: the
  5. * loading page must work while — especially when — plugins fail). The one
  6. * sanctioned exception is the modules package (design §4.7 bootstrap
  7. * identity): the module system cannot arrive through itself, so its class
  8. * and its client-half wrapper are shell-bundled and the kernel adopts its
  9. * plugin entry once cordis is up.
  10. *
  11. * AppWebEntry.run(), module face first, then plugin face: parse
  12. * `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
  13. * → build the module system over the module-view rows → render the loading
  14. * page → prefetch every `immediately` row in parallel with mounting the
  15. * vendored cordis Loader (internal-seam injection BEFORE any entry exists —
  16. * the bare-import fallback in tree.import must never run in a browser) →
  17. * await the prefetch tier, THEN adopt the modules entry and create one
  18. * loader entry per plugin-view row plus the shell-own app-shell assembly
  19. * entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
  20. * listing who/what/which service) → flip the settled signal so AppRoot
  21. * switches to the real UI in one pass.
  22. *
  23. * Entry creation waits for the whole immediately tier: materialization runs
  24. * synchronous cross-package require edges (e.g. locale → runtime/client) that
  25. * fiber inject waiting cannot protect — a bundle's factory must be
  26. * registered before any dependent entry materializes. Per-row prefetch
  27. * failures still resolve silently (the create-side import refetches and
  28. * owns the loud failure), so the barrier never turns one bad bundle into a
  29. * boot-wide fail-fast.
  30. *
  31. * Composition lives in the host graph; the shell makes zero composition
  32. * decisions (the app-shell assembly is itself a graph entry, the only
  33. * shell-own module registered with the module system).
  34. */
  35. import { Context } from 'cordis'
  36. import Loader from '@cordisjs/plugin-loader'
  37. import { createRoot, type Root } from 'react-dom/client'
  38. import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
  39. import {
  40. ClientModuleSystem, parseBootManifest,
  41. type BootManifest, type ClientModuleSystemOptions, type DshWindow,
  42. } from '@deepseek-ai/dsh-client-modules/client'
  43. import * as AppShell from './app-shell.ts'
  44. import { APP_SHELL_ID } from './app-shell.ts'
  45. import { AppRoot } from './AppRoot.tsx'
  46. import { getStaticModules } from './seed.ts'
  47. import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
  48. import './base.css'
  49. /** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
  50. export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
  51. /**
  52. * The modules package's own graph row id. The kernel adopts that entry
  53. * itself (its wrapper is statically registered — shell-bundled code, never
  54. * fetched), so the plugin-row loop must skip it: the vendored Group.create
  55. * does not deduplicate by name, and a second fiber would provide 'modules'
  56. * twice.
  57. */
  58. const MODULES_ID = '@deepseek-ai/dsh-client-modules'
  59. /**
  60. * The web shell kernel: mounts the loading page into a DOM element and runs
  61. * the two-stage boot over the host graph. Fields hold only what must exist
  62. * before cordis does — the parsed manifest, the module system, and the
  63. * loading-page UI handles; everything else lives in plugins.
  64. */
  65. export class AppWebEntry {
  66. private readonly el: HTMLElement
  67. private readonly seams: BootSeams | undefined
  68. private readonly status = createLoaderStatusStore()
  69. private readonly settled = createSignal(false)
  70. private readonly error = createSignal<string | undefined>(undefined)
  71. // Assigned by run() before any private method or settled-gated closure reads them.
  72. private ctx!: Context
  73. private modules!: ClientModuleSystem
  74. private manifest!: BootManifest
  75. private root: Root | undefined
  76. /**
  77. * Hold the mount point; all work happens in {@link run}.
  78. * @param el - mount point (the app's #root).
  79. * @param seams - optional module transport overrides (test environments).
  80. */
  81. constructor(el: HTMLElement, seams?: BootSeams) {
  82. this.el = el
  83. this.seams = seams
  84. }
  85. /**
  86. * Run the boot chain to settlement. Boot-chain failures resolve (not
  87. * reject): the loading page stays up and renders the failure report (the
  88. * fail-loud surface the kernel owns). Rejects only when the boot manifest
  89. * is missing or malformed — there is nothing to boot against.
  90. * @returns resolves once the UI settled or the failure report rendered.
  91. */
  92. async run(): Promise<void> {
  93. this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
  94. this.modules = new ClientModuleSystem({
  95. modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
  96. })
  97. // The app-shell assembly is the only shell-own module: every other graph
  98. // row is a plugin bundle arriving through fetch (web2 single package form).
  99. this.modules.registerStatic(APP_SHELL_ID, AppShell)
  100. // Adoption handoff, supply side (design §4.7): register the modules
  101. // package's own client half under its bare package name (= graph row id
  102. // = entry name — a suffixed key would miss the statics branch and
  103. // trigger a real fetch), and put the instance on the kernel slot the
  104. // wrapper's apply reads to provide ctx.modules.
  105. this.modules.registerStatic(MODULES_ID, ModulesClient)
  106. ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
  107. this.root = createRoot(this.el)
  108. this.root.render(
  109. <AppRoot
  110. settled={this.settled}
  111. status={this.status}
  112. error={this.error}
  113. renderApp={() => {
  114. const shell = this.ctx.get('appShell')
  115. // Unreachable after a clean settle (the app-shell entry is in every graph).
  116. if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
  117. return shell.renderApp()
  118. }}
  119. />,
  120. )
  121. // The immediately tier prefetches in parallel with Loader mounting;
  122. // runPluginBoot awaits it before creating entries (see module comment:
  123. // cross-package synchronous require edges need every immediately-tier
  124. // factory registered before any materialization).
  125. const prefetching = this.prefetchImmediateTier()
  126. this.ctx = new Context()
  127. try {
  128. await this.runPluginBoot(prefetching)
  129. this.settled.set(true)
  130. } catch (reason) {
  131. // Stay on the loading page; surface the sweep report (fail loud).
  132. console.error(reason)
  133. this.error.set(reason instanceof Error ? reason.message : String(reason))
  134. }
  135. }
  136. /** Unmount the shell (loading page or settled UI). */
  137. dispose(): void {
  138. this.root?.unmount()
  139. }
  140. /** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
  141. private async prefetchImmediateTier(): Promise<void> {
  142. await Promise.all(this.manifest.plugins
  143. .filter(row => row.immediately)
  144. .map(row => this.modules.prefetch(row.id).catch(() => {
  145. // Import refetches and reports this loudly per entry; swallowing
  146. // here keeps one failing prefetch from masking the others.
  147. })))
  148. }
  149. /** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
  150. private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
  151. const ctx = this.ctx
  152. await ctx.plugin(Loader)
  153. const loader = ctx.loader
  154. // Inject the module system BEFORE any entry exists: tree.import falls back
  155. // to a bare dynamic import when internal is undefined, which in a browser
  156. // is a guaranteed loud failure — correct as a tripwire, never as a path.
  157. loader.internal = this.modules as never
  158. // Status projection: AppRoot displays fiber truth. Every internal/status
  159. // transition under an entry re-projects that entry's row from its ROOT
  160. // fiber (child plugin fibers share the same entry).
  161. ctx.on('internal/status', (fiber) => {
  162. const entry = fiber.entry
  163. if (entry === undefined || entry.fiber === undefined) return
  164. this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
  165. })
  166. // Barrier before any entry exists: entry creation materializes bundles,
  167. // and materialization runs synchronous cross-package require edges that
  168. // need every immediately-tier factory already registered (module
  169. // comment). Resolves even when individual prefetches failed.
  170. await prefetching
  171. // Adoption handoff, plugin side: the modules entry is created first —
  172. // its wrapper apply reads the kernel slot and provides ctx.modules (the
  173. // provide lives on the plugin face; see MODULES_ID for why the row loop
  174. // must then skip it).
  175. const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
  176. // Entry creation order carries no semantics (fiber inject waiting owns
  177. // activation order); creating concurrently lets non-prefetched bundle
  178. // fetches parallelize. The app-shell assembly entry is appended by the
  179. // kernel: it is shell-own code (host graph rows are all plugin bundles),
  180. // and mounting the assembly is not a composition decision — it rides the
  181. // same entry lifecycle so the sweep and status cover it uniformly.
  182. await Promise.all(rows.map(async (name) => {
  183. this.status.set(name, 'loading')
  184. const id = await loader.create({ name })
  185. // A failed import leaves the entry fiberless (Entry._init logs and
  186. // returns); project it as failed — no fiber means no status event.
  187. if (loader.resolve(id).fiber === undefined) {
  188. this.status.set(name, 'failed')
  189. }
  190. }))
  191. await loader.await()
  192. this.assertEntriesActive()
  193. }
  194. /**
  195. * Sweep every loader entry after the tree quiesced: an entry without a
  196. * fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
  197. * PENDING (a required service never arrived — cordis inject waiting has no
  198. * timeout, so this sweep is the fail-loud compensation).
  199. */
  200. private assertEntriesActive(): void {
  201. const ctx = this.ctx
  202. const failures: string[] = []
  203. for (const entry of ctx.loader.entries()) {
  204. const name = entry.options.name
  205. if (entry.fiber === undefined) {
  206. failures.push(`${name}: import failed (see console for the import error)`)
  207. continue
  208. }
  209. const state = STATE_LABELS[entry.fiber.state]
  210. if (state === 'active') continue
  211. if (state === 'pending') {
  212. const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
  213. failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
  214. } else {
  215. failures.push(`${name}: ${state}`)
  216. }
  217. }
  218. if (failures.length > 0) {
  219. throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
  220. }
  221. }
  222. }