boot.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * Web boot kernel. It owns only the module system, Cordis loader, and a
  3. * framework-free boot page. The dynamic UI renderer receives the mount
  4. * point after every client entry activates.
  5. * @module @deepseek-ai/dsh-client-web/src/boot
  6. */
  7. import { Context } from '@deepseek-ai/cordis'
  8. import Loader from '@deepseek-ai/cordis-plugin-loader'
  9. import type {
  10. BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
  11. } from '@deepseek-ai/dsh-client-modules/client'
  12. import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
  13. import { BootPage } from './boot-page.ts'
  14. import { getStaticModules } from './seed.ts'
  15. import { STATE_LABELS } from './loader-status.ts'
  16. import './base.css'
  17. /** Module transport hook replaced by jsdom tests. */
  18. export type BootSeams = Pick<ClientModuleCreateOptions, 'loadBundle'>
  19. /** Browser boot entry consumed by `apps/web`. */
  20. export class AppWebEntry {
  21. private readonly container: HTMLElement
  22. private readonly seams: BootSeams | undefined
  23. private readonly page: BootPage
  24. private ctx: Context | undefined
  25. private modules!: ClientModuleSystem
  26. private manifest!: BootManifest
  27. /**
  28. * Draw the boot page; {@link run} starts the loader.
  29. * @param container - Application mount point.
  30. * @param seams - Optional module transport replacement.
  31. */
  32. constructor(container: HTMLElement, seams?: BootSeams) {
  33. this.container = container
  34. this.seams = seams
  35. this.page = new BootPage(container)
  36. }
  37. /**
  38. * Load and activate every client entry, then hand the mount point to the
  39. * UI renderer. Plugin failures remain visible on the boot page.
  40. * @returns Resolves after application mount or failure rendering.
  41. */
  42. async run(): Promise<void> {
  43. try {
  44. // Boot-readiness gate: whichever bootstrap applies the injection table
  45. // settles this deferred once every row has taken effect — the served
  46. // index resolves it in the rendered tail, so the await returns on the
  47. // next microtask; an asynchronous bootstrap resolves it after its last
  48. // row, or rejects it into the failure rendering below. An absent global
  49. // means no bootstrap owns the document and there is nothing to wait for.
  50. await (globalThis as { __DSH_BOOT_READY__?: { promise: Promise<void> } }).__DSH_BOOT_READY__?.promise
  51. const win = globalThis as DshWindow
  52. const moduleLoader = win.__ModuleLoader__
  53. if (moduleLoader === undefined) {
  54. throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
  55. }
  56. // A pre-injected transport (the worker preview page) owns bundle bytes;
  57. // its loadBundle is the default and explicit seams still win. The global
  58. // is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection;
  59. // this structural slice reads one optional member without adding a
  60. // package edge.
  61. const transport = (globalThis as {
  62. __DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] }
  63. }).__DSH_TRANSPORT__
  64. this.modules = moduleLoader.create({
  65. boot: win.__DSH_BOOT__,
  66. staticModules: getStaticModules(),
  67. ...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
  68. ...this.seams,
  69. })
  70. this.manifest = this.modules.manifest
  71. const prefetching = this.prefetchImmediateTier()
  72. const ctx = new Context()
  73. this.ctx = ctx
  74. await this.runPluginBoot(ctx, prefetching)
  75. await this.mountApp(ctx)
  76. } catch (reason) {
  77. console.error(reason)
  78. this.page.fail(reason instanceof Error ? reason.message : String(reason))
  79. }
  80. }
  81. /** Dispose the client plugin tree and whichever page owns the mount point. */
  82. async dispose(): Promise<void> {
  83. const ctx = this.ctx
  84. this.ctx = undefined
  85. if (ctx !== undefined) await ctx.fiber.dispose()
  86. this.page.dispose()
  87. }
  88. /** Mount through a dependency fiber so replacing uiRenderer remounts the application. */
  89. private async mountApp(ctx: Context): Promise<void> {
  90. const mounted = ctx.inject(['uiRenderer'], (scope) => {
  91. scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
  92. })
  93. await mounted
  94. }
  95. /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
  96. private async prefetchImmediateTier(): Promise<void> {
  97. await Promise.all(this.manifest.plugins
  98. .filter(row => row.immediately)
  99. .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {
  100. // Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
  101. })))
  102. }
  103. /** Mount the Loader, create all graph entries, await quiescence, and audit activation. */
  104. private async runPluginBoot(ctx: Context, prefetching: Promise<void>): Promise<void> {
  105. await ctx.plugin(Loader)
  106. const loader = ctx.loader
  107. loader.internal = this.modules as never
  108. ctx.on('internal/status', (fiber) => {
  109. const entry = fiber.entry
  110. if (entry === undefined || entry.fiber === undefined) return
  111. this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state])
  112. })
  113. const rows = this.manifest.plugins.map(row => row.id)
  114. this.page.setTotal(rows.length)
  115. await prefetching
  116. await Promise.all(rows.map(async (name) => {
  117. this.page.setState(name, 'loading')
  118. const id = await loader.create({ name })
  119. if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed')
  120. }))
  121. await loader.await()
  122. this.assertEntriesActive(ctx)
  123. }
  124. /** Reject entries that failed import/apply or still wait on missing services. */
  125. private assertEntriesActive(ctx: Context): void {
  126. const failures: string[] = []
  127. for (const entry of ctx.loader.entries()) {
  128. const name = entry.options.name
  129. if (entry.fiber === undefined) {
  130. failures.push(`${name}: import failed (see console for the import error)`)
  131. continue
  132. }
  133. const state = STATE_LABELS[entry.fiber.state]
  134. if (state === 'active') continue
  135. if (state === 'pending') {
  136. const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
  137. failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
  138. } else {
  139. failures.push(`${name}: ${state}`)
  140. }
  141. }
  142. if (failures.length > 0) {
  143. throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
  144. }
  145. }
  146. }