* 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.
150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
/**
|
|
* Production client composition without the page: mount the Loader over a
|
|
* module system, create every manifest row, wait for quiescence, and audit
|
|
* activation. `AppWebEntry` and the whole-client test carrier both call it.
|
|
* @module @deepseek-ai/dsh-client-web/src/boot-client
|
|
*/
|
|
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client'
|
|
import { STATE_LABELS } from './loader-status.ts'
|
|
|
|
/** Entry state label as the boot page renders it. */
|
|
export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed'
|
|
|
|
/**
|
|
* Entry names whose presence defines a usable Web client.
|
|
*
|
|
* The list is global rather than page or profile metadata. A name absent from
|
|
* the boot manifest does not affect startup; a listed entry that is present
|
|
* must activate. The list covers the module system, Session control, the
|
|
* renderer and application frame, and the Session view with its sidebar. It is
|
|
* the minimum fatal set: an entry that a listed entry waits on leaves it
|
|
* inactive, so that entry's failure also rejects startup.
|
|
*/
|
|
const requiredClientEntryNames = new Set<string>([
|
|
'@deepseek-ai/dsh-client-modules',
|
|
'@deepseek-ai/dsh-api-session-controller',
|
|
'@deepseek-ai/dsh-client-ui-renderer',
|
|
'@deepseek-ai/dsh-client-ui-layout',
|
|
'@deepseek-ai/dsh-client-ui-session',
|
|
'@deepseek-ai/dsh-client-ui-chat',
|
|
'@deepseek-ai/dsh-client-ui-sidebar',
|
|
])
|
|
|
|
/** Inputs of {@link bootClient}. */
|
|
export interface ClientBootOptions {
|
|
/** Fresh root Context that will own the plugin tree. */
|
|
readonly ctx: Context
|
|
/** Module system installed as `loader.internal`. */
|
|
readonly modules: ClientModuleLoader
|
|
/** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */
|
|
readonly manifest: BootManifest
|
|
/** Per-entry state reporting (the boot page); omitted when no one renders progress. */
|
|
readonly onEntryState?: (name: string, state: EntryStateLabel) => void
|
|
}
|
|
|
|
/**
|
|
* Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one
|
|
* `loader.create({ name })` per manifest row, `loader.await()`, then
|
|
* {@link assertEntriesActive}. A row whose module cannot be imported is marked
|
|
* failed; the Loader logs its import error, the module system records it, and
|
|
* the audit reports that error text per entry.
|
|
* @param options - context, module system, manifest, optional progress sink.
|
|
* @returns resolves after the audit accepts the tree, including after optional-entry warnings;
|
|
* rejects with the audit report when a required entry is inactive.
|
|
*/
|
|
export async function bootClient(options: ClientBootOptions): Promise<void> {
|
|
const { ctx, manifest, onEntryState } = options
|
|
await ctx.plugin(Loader)
|
|
const loader = ctx.loader
|
|
loader.internal = options.modules as never
|
|
|
|
ctx.on('internal/status', (fiber) => {
|
|
const entry = fiber.entry
|
|
if (entry === undefined || entry.fiber === undefined) return
|
|
onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state])
|
|
})
|
|
|
|
const rows = manifest.plugins.map(row => row.id)
|
|
for (const name of rows) onEntryState?.(name, 'loading')
|
|
await options.modules.entries.start(loader, manifest)
|
|
for (const entry of loader.entries()) {
|
|
if (entry.fiber === undefined) onEntryState?.(entry.options.name, 'failed')
|
|
}
|
|
|
|
await loader.await()
|
|
await assertEntriesActive(ctx, options.modules)
|
|
}
|
|
|
|
/**
|
|
* Audit entries that failed import/apply or still wait on missing services.
|
|
* An inactive entry named in the required list rejects startup; other
|
|
* inactive entries produce one warning and leave active siblings running.
|
|
* A failed entry reports its recorded activation error; reading it awaits
|
|
* only failed fibers, which have settled by the time the Loader has.
|
|
* @param ctx - root Context carrying the Loader.
|
|
* @param modules - the module system whose recorded import failures name why an entry
|
|
* has no fiber; a row with no record points at the console. An entry without a fiber
|
|
* failed to import or was rejected at creation (its module exports no plugin); like the
|
|
* Host audit's `failed to import`, both are reported as an import failure.
|
|
* @param warn - sink for the optional-entry warning.
|
|
* @returns resolves after any optional-entry warning.
|
|
* @throws {Error} rejects when a required entry is inactive, listing every non-active entry with its
|
|
* reason and marking the required ones; no warning is written in that case.
|
|
*/
|
|
export async function assertEntriesActive(
|
|
ctx: Context,
|
|
modules: Pick<ClientModuleLoader, 'importError'>,
|
|
warn: (message: string) => void = (message) => { console.warn(message) },
|
|
): Promise<void> {
|
|
const failures: { name: string; reason: string }[] = []
|
|
for (const entry of ctx.loader.entries()) {
|
|
const name = entry.options.name
|
|
if (entry.fiber === undefined) {
|
|
const importError = modules.importError(name)
|
|
failures.push({
|
|
name,
|
|
reason: importError === undefined
|
|
? 'import failed (see console for the import error)'
|
|
: `import failed: ${importError.message}`,
|
|
})
|
|
continue
|
|
}
|
|
const state = STATE_LABELS[entry.fiber.state]
|
|
if (state === 'active') continue
|
|
if (state === 'pending') {
|
|
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
|
failures.push({ name, reason: `pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})` })
|
|
} else if (state === 'failed') {
|
|
failures.push({ name, reason: await activationFailure(entry.fiber) })
|
|
} else {
|
|
failures.push({ name, reason: state })
|
|
}
|
|
}
|
|
if (failures.length === 0) return
|
|
const isRequired = (name: string): boolean => requiredClientEntryNames.has(name)
|
|
const required = failures.filter(({ name }) => isRequired(name)).length
|
|
if (required > 0) {
|
|
const lines = failures.map(({ name, reason }) => `${name}${isRequired(name) ? ' (required)' : ''}: ${reason}`)
|
|
throw new Error(`web boot: ${String(required)} required ${entryNoun(required)} did not activate\n${lines.join('\n')}`)
|
|
}
|
|
const lines = failures.map(({ name, reason }) => `${name}: ${reason}`)
|
|
warn(`web boot: warning: ${String(failures.length)} ${entryNoun(failures.length)} did not activate\n${lines.join('\n')}`)
|
|
}
|
|
|
|
/** Singular or plural noun for an entry count in audit reports. */
|
|
function entryNoun(count: number): string {
|
|
return count === 1 ? 'entry' : 'entries'
|
|
}
|
|
|
|
/** A failed fiber's recorded activation error; Cordis exposes it only through `await()`, which rethrows it. */
|
|
async function activationFailure(fiber: Pick<Fiber, 'await'>): Promise<string> {
|
|
try {
|
|
await fiber.await()
|
|
} catch (error) {
|
|
return `failed: ${String(error)}`
|
|
}
|
|
return 'failed'
|
|
}
|