boot.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /**
  2. * Web boot kernel. It owns only the module system, Cordis loader, and a
  3. * framework-free boot page; plugin composition and the renderer handoff are
  4. * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount
  5. * point after every client entry activates.
  6. * @module @deepseek-ai/dsh-client-web/src/boot
  7. */
  8. import { Context } from '@deepseek-ai/cordis'
  9. import type {
  10. BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
  11. } from '@deepseek-ai/dsh-client-modules/client'
  12. import { bootClient } from './boot-client.ts'
  13. import { BootPage } from './boot-page.ts'
  14. import { mountClient } from './mount.ts'
  15. import { getStaticModules } from './seed.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. this.page.setTotal(this.manifest.plugins.length)
  75. await prefetching
  76. await bootClient({
  77. ctx,
  78. modules: this.modules,
  79. manifest: this.manifest,
  80. onEntryState: (name, state) => { this.page.setState(name, state) },
  81. })
  82. await mountClient(ctx, this.container)
  83. } catch (reason) {
  84. console.error(reason)
  85. this.page.fail(reason instanceof Error ? reason.message : String(reason))
  86. }
  87. }
  88. /** Dispose the client plugin tree and whichever page owns the mount point. */
  89. async dispose(): Promise<void> {
  90. const ctx = this.ctx
  91. this.ctx = undefined
  92. if (ctx !== undefined) await ctx.fiber.dispose()
  93. this.page.dispose()
  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. }