boot-client.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. /**
  2. * Production client composition without the page: mount the Loader over a
  3. * module system, create every manifest row, wait for quiescence, and audit
  4. * activation. `AppWebEntry` and the whole-client test carrier both call it.
  5. * @module @deepseek-ai/dsh-client-web/src/boot-client
  6. */
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import Loader from '@deepseek-ai/cordis-plugin-loader'
  9. import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
  10. import { STATE_LABELS } from './loader-status.ts'
  11. /** Entry state label as the boot page renders it. */
  12. export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed'
  13. /** Inputs of {@link bootClient}. */
  14. export interface ClientBootOptions {
  15. /** Fresh root Context that will own the plugin tree. */
  16. readonly ctx: Context
  17. /** Module system installed as `loader.internal`. */
  18. readonly modules: ClientModuleLoader
  19. /** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */
  20. readonly manifest: BootManifest
  21. /** Per-entry state reporting (the boot page); omitted when no one renders progress. */
  22. readonly onEntryState?: (name: string, state: EntryStateLabel) => void
  23. }
  24. /**
  25. * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
  26. * `loader.create({ name })` per manifest row, `loader.await()`, then
  27. * {@link assertEntriesActive}. A row whose module cannot be imported is marked
  28. * failed; the Loader logs its import error and the audit rejects startup.
  29. * @param options - context, module system, manifest, optional progress sink.
  30. * @returns resolves after every entry is active; rejects with the audit report otherwise.
  31. */
  32. export async function bootClient(options: ClientBootOptions): Promise<void> {
  33. const { ctx, manifest, onEntryState } = options
  34. await ctx.plugin(Loader)
  35. const loader = ctx.loader
  36. loader.internal = options.modules as never
  37. ctx.on('internal/status', (fiber) => {
  38. const entry = fiber.entry
  39. if (entry === undefined || entry.fiber === undefined) return
  40. onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state])
  41. })
  42. const rows = manifest.plugins.map(row => row.id)
  43. await Promise.all(rows.map(async (name) => {
  44. onEntryState?.(name, 'loading')
  45. const id = await loader.create({ name })
  46. if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed')
  47. }))
  48. await loader.await()
  49. assertEntriesActive(ctx)
  50. }
  51. /**
  52. * Reject entries that failed import/apply or still wait on missing services.
  53. * @param ctx - root Context carrying the Loader.
  54. * @throws {Error} listing every non-active entry with its reason.
  55. */
  56. export function assertEntriesActive(ctx: Context): void {
  57. const failures: string[] = []
  58. for (const entry of ctx.loader.entries()) {
  59. const name = entry.options.name
  60. if (entry.fiber === undefined) {
  61. failures.push(`${name}: import failed (see console for the import error)`)
  62. continue
  63. }
  64. const state = STATE_LABELS[entry.fiber.state]
  65. if (state === 'active') continue
  66. if (state === 'pending') {
  67. const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
  68. failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
  69. } else {
  70. failures.push(`${name}: ${state}`)
  71. }
  72. }
  73. if (failures.length > 0) {
  74. throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
  75. }
  76. }