125 lines
5.4 KiB
TypeScript
125 lines
5.4 KiB
TypeScript
/**
|
|
* Web boot kernel. It owns only the module system, Cordis loader, and a
|
|
* 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 type {
|
|
BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow,
|
|
} from '@deepseek-ai/dsh-client-modules/client'
|
|
import { bootClient } from './boot-client.ts'
|
|
import { BootPage } from './boot-page.ts'
|
|
import { mountClient } from './mount.ts'
|
|
import { getStaticModules } from './seed.ts'
|
|
import { installWindowDragRecall } from './window-drag/recall.ts'
|
|
import './base.css'
|
|
|
|
/** Module transport hook replaced by jsdom tests. */
|
|
export type BootSeams = Pick<ClientModuleCreateOptions, 'loadBundle' | 'resolveAsset'>
|
|
|
|
/** Browser boot entry consumed by `apps/web`. */
|
|
export class AppWebEntry {
|
|
private readonly container: HTMLElement
|
|
private readonly seams: BootSeams | undefined
|
|
private readonly page: BootPage
|
|
private ctx: Context | undefined
|
|
private stopDragRecall: (() => void) | undefined
|
|
private modules!: ClientModuleSystem
|
|
private manifest!: BootManifest
|
|
|
|
/**
|
|
* Draw the boot page; {@link run} starts the loader.
|
|
* @param container - Application mount point.
|
|
* @param seams - Optional module transport replacement.
|
|
*/
|
|
constructor(container: HTMLElement, seams?: BootSeams) {
|
|
this.container = container
|
|
this.seams = seams
|
|
this.page = new BootPage(container)
|
|
}
|
|
|
|
/**
|
|
* Load and activate every client entry, then hand the mount point to the
|
|
* UI renderer. Plugin failures remain visible on the boot page.
|
|
* @param onFailure - Optional carrier-owned fatal presentation; keeps the boot page visible.
|
|
* @returns Resolves after application mount or failure reporting.
|
|
*/
|
|
async run(onFailure?: (reason: unknown) => void): Promise<void> {
|
|
try {
|
|
// Boot-readiness gate: whichever bootstrap applies the injection table
|
|
// settles this deferred once every row has taken effect — the served
|
|
// index resolves it in the rendered tail, so the await returns on the
|
|
// next microtask; an asynchronous bootstrap resolves it after its last
|
|
// row, or rejects it into the failure rendering below. An absent global
|
|
// means no bootstrap owns the document and there is nothing to wait for.
|
|
await (globalThis as { __DSH_BOOT_READY__?: { promise: Promise<void> } }).__DSH_BOOT_READY__?.promise
|
|
const win = globalThis as DshWindow
|
|
const moduleLoader = win.__ModuleLoader__
|
|
if (moduleLoader === undefined) {
|
|
throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
|
|
}
|
|
// A pre-injected transport (the worker preview page) owns bundle and asset
|
|
// bytes; its loadBundle and resolveAsset are the defaults and explicit seams still win. The global
|
|
// is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection;
|
|
// this structural slice reads two optional members without adding a
|
|
// package edge.
|
|
const transport = (globalThis as {
|
|
__DSH_TRANSPORT__?: Pick<ClientModuleCreateOptions, 'loadBundle' | 'resolveAsset'>
|
|
}).__DSH_TRANSPORT__
|
|
this.modules = moduleLoader.create({
|
|
boot: win.__DSH_BOOT__,
|
|
staticModules: getStaticModules(),
|
|
...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
|
|
...transport?.resolveAsset === undefined ? {} : { resolveAsset: transport.resolveAsset },
|
|
...this.seams,
|
|
})
|
|
this.manifest = this.modules.manifest
|
|
|
|
const prefetching = this.prefetchImmediateTier()
|
|
const ctx = new Context()
|
|
this.ctx = ctx
|
|
this.page.setTotal(this.manifest.plugins.length)
|
|
await prefetching
|
|
await bootClient({
|
|
ctx,
|
|
modules: this.modules,
|
|
manifest: this.manifest,
|
|
onEntryState: (name, state) => {
|
|
if (onFailure === undefined || state !== 'failed') this.page.setState(name, state)
|
|
},
|
|
})
|
|
// The shell owns the one watcher that keeps Electron's window drag rects in
|
|
// step with the rows that own them (electron#32341), so no chrome row has to
|
|
// know that trap. It installs before the first mount, so the surface the
|
|
// renderer draws is the one the first frame measures.
|
|
this.stopDragRecall = installWindowDragRecall({ document: this.container.ownerDocument })
|
|
await mountClient(ctx, this.container)
|
|
} catch (reason) {
|
|
console.error(reason)
|
|
if (onFailure !== undefined) onFailure(reason)
|
|
else this.page.fail(reason instanceof Error ? reason.message : String(reason))
|
|
}
|
|
}
|
|
|
|
/** Dispose the client plugin tree and whichever page owns the mount point. */
|
|
async dispose(): Promise<void> {
|
|
this.stopDragRecall?.()
|
|
this.stopDragRecall = undefined
|
|
const ctx = this.ctx
|
|
this.ctx = undefined
|
|
if (ctx !== undefined) await ctx.fiber.dispose()
|
|
this.page.dispose()
|
|
}
|
|
|
|
/** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
|
|
private async prefetchImmediateTier(): Promise<void> {
|
|
await Promise.all(this.manifest.plugins
|
|
.filter(row => row.immediately)
|
|
.map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {
|
|
// Prefetch only starts transport early; the Loader import retries and reports this bundle failure.
|
|
})))
|
|
}
|
|
}
|