Files
imccyu 6e77ea0131 feat(web): boot past failed optional client plugins (#7733)
* feat(web): boot past failed optional client plugins

The Web client boot audit now follows Host startup strictness. An
inactive entry on the global required list (module system, Session
transport, renderer, application frame, Session view, and sidebar)
still rejects startup and the report marks the required entries.
Other inactive entries produce one console warning and the
application mounts without them. The boot page no longer draws a
failed entry before the audit decides.

* fix(web): audit boot entries whose creation rejects and name activation errors

ClientEntries.start() now logs a row whose Loader creation rejects, such
as a module that exports no plugin, and leaves it inactive for the boot
audit, as the Host Loader group does. The audit awaits only failed
fibers to report their recorded activation error. Notes, READMEs, and
the Web client subsystem page describe the browser boot audit; tests
cover invalid exports, carrier presentation of optional failures, and
required names absent from the boot graph.

* test(web): fail test carriers and e2e on inactive client entries

The product boot only warns about inactive entries outside its required
list, but tests must still surface every plugin that fails. TestClient
and createClientTest now require every roster entry to activate unless
a case passes allowInactiveEntries. The watchConsole tripwire, the
real-host smoke, and the assembled jsdom boot treat a web boot warning
as a failure with no opt-out.

Docs: the Web README names Session control, notes that an entry without
a fiber is reported as an import failure like the Host audit's "failed
to import", and the startup strictness note lists the browser tests.
2026-11-02 16:20:50 +00:00

128 lines
5.7 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 the activation audit accepts the client tree.
* @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, audit activation, then hand the
* mount point to the UI renderer. A required-entry failure remains visible
* on the boot page; optional-entry failures are reported to the console and
* the application mounts without them.
* @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,
// A failed entry may be optional; only the audit decides whether the boot page reports a failure.
onEntryState: (name, state) => {
if (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.
})))
}
}