|
|
@@ -1,23 +1,32 @@
|
|
|
/**
|
|
|
- * Web shell boot — the kernel face consumed by the apps/web entry. Everything
|
|
|
- * here is machinery that cannot itself be an entry, and none of it
|
|
|
+ * Web shell boot kernel — the face consumed by the apps/web entry. Everything
|
|
|
+ * here is machinery that cannot itself be a loader entry, and none of it
|
|
|
* value-imports a plugin package (web2 shell self-sufficiency rule: the
|
|
|
- * loading page must work while — especially when — plugins fail).
|
|
|
+ * loading page must work while — especially when — plugins fail). The one
|
|
|
+ * sanctioned exception is the modules package (design §4.7 bootstrap
|
|
|
+ * identity): the module system cannot arrive through itself, so its class
|
|
|
+ * and its client-half wrapper are shell-bundled and the kernel adopts its
|
|
|
+ * plugin entry once cordis is up.
|
|
|
*
|
|
|
- * Two-stage boot (web2 §0):
|
|
|
- * Stage one (module face): build the module system over the host graph
|
|
|
- * (`window.__DSH_BOOT__`) and prefetch every `immediately` row in parallel
|
|
|
- * — fetch + execute registers factories only; module side effects wait for
|
|
|
- * materialization. Prefetch failures are non-fatal here: stage two's
|
|
|
- * import path retries the fetch and owns the loud failure.
|
|
|
- * Stage two (plugin face): mount the vendored cordis Loader, inject the
|
|
|
- * module system as its internal seam (BEFORE any entry exists — the
|
|
|
- * bare-import fallback in tree.import must never run in a browser), create
|
|
|
- * one loader entry per graph row (tree.import materializes each module),
|
|
|
- * let fibers activate on service availability, then loader.await() + a
|
|
|
- * full fiber sweep (all ACTIVE, else reject listing who/what/which
|
|
|
- * service) → flip the settled signal so AppRoot switches to the real UI in
|
|
|
- * one pass.
|
|
|
+ * AppWebEntry.run(), module face first, then plugin face: parse
|
|
|
+ * `window.__DSH_BOOT__` into the two-view BootManifest (wire boundary, D16)
|
|
|
+ * → build the module system over the module-view rows → render the loading
|
|
|
+ * page → prefetch every `immediately` row in parallel with mounting the
|
|
|
+ * vendored cordis Loader (internal-seam injection BEFORE any entry exists —
|
|
|
+ * the bare-import fallback in tree.import must never run in a browser) →
|
|
|
+ * await the prefetch tier, THEN adopt the modules entry and create one
|
|
|
+ * loader entry per plugin-view row plus the shell-own app-shell assembly
|
|
|
+ * entry → loader.await() + a full fiber sweep (all ACTIVE, else fail
|
|
|
+ * listing who/what/which service) → flip the settled signal so AppRoot
|
|
|
+ * switches to the real UI in one pass.
|
|
|
+ *
|
|
|
+ * Entry creation waits for the whole immediately tier: materialization runs
|
|
|
+ * synchronous cross-package require edges (e.g. i18n → runtime/client) that
|
|
|
+ * fiber inject waiting cannot protect — a bundle's factory must be
|
|
|
+ * registered before any dependent entry materializes. Per-row prefetch
|
|
|
+ * failures still resolve silently (the create-side import refetches and
|
|
|
+ * owns the loud failure), so the barrier never turns one bad bundle into a
|
|
|
+ * boot-wide fail-fast.
|
|
|
*
|
|
|
* Composition lives in the host graph; the shell makes zero composition
|
|
|
* decisions (the app-shell assembly is itself a graph entry, the only
|
|
|
@@ -25,148 +34,205 @@
|
|
|
*/
|
|
|
import { Context } from 'cordis'
|
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
|
-import { createRoot } from 'react-dom/client'
|
|
|
+import { createRoot, type Root } from 'react-dom/client'
|
|
|
+import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client'
|
|
|
import {
|
|
|
- createClientModuleLoader,
|
|
|
- type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
|
|
|
-} from '@deepseek-ai/dsh-client-modules'
|
|
|
+ ClientModuleSystem, parseBootManifest,
|
|
|
+ type BootManifest, type ClientModuleSystemOptions, type DshWindow,
|
|
|
+} from '@deepseek-ai/dsh-client-modules/client'
|
|
|
import * as AppShell from './app-shell.ts'
|
|
|
import { APP_SHELL_ID } from './app-shell.ts'
|
|
|
import { AppRoot } from './AppRoot.tsx'
|
|
|
import { getStaticModules } from './seed.ts'
|
|
|
-import {
|
|
|
- STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
|
|
|
-} from './loader-status.ts'
|
|
|
+import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
|
|
|
import './base.css'
|
|
|
|
|
|
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
|
|
-export type BootSeams = Pick<ClientModuleLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
|
|
+export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
|
|
|
|
|
|
/**
|
|
|
- * Sweep every loader entry after the tree quiesced: an entry without a fiber
|
|
|
- * failed its import; a fiber not ACTIVE is FAILED (apply threw) or PENDING
|
|
|
- * (a required service never arrived — cordis inject waiting has no timeout,
|
|
|
- * so this sweep is the fail-loud compensation).
|
|
|
+ * The modules package's own graph row id. The kernel adopts that entry
|
|
|
+ * itself (its wrapper is statically registered — shell-bundled code, never
|
|
|
+ * fetched), so the plugin-row loop must skip it: the vendored Group.create
|
|
|
+ * does not deduplicate by name, and a second fiber would provide 'modules'
|
|
|
+ * twice.
|
|
|
*/
|
|
|
-function assertEntriesActive(ctx: Context): void {
|
|
|
- const failures: string[] = []
|
|
|
- for (const entry of ctx.loader.entries()) {
|
|
|
- const name = entry.options.name
|
|
|
- if (entry.fiber === undefined) {
|
|
|
- failures.push(`${name}: import failed (see console for the import error)`)
|
|
|
- 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}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
|
|
- } else {
|
|
|
- failures.push(`${name}: ${state}`)
|
|
|
+const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
|
+
|
|
|
+/**
|
|
|
+ * The web shell kernel: mounts the loading page into a DOM element and runs
|
|
|
+ * the two-stage boot over the host graph. Fields hold only what must exist
|
|
|
+ * before cordis does — the parsed manifest, the module system, and the
|
|
|
+ * loading-page UI handles; everything else lives in plugins.
|
|
|
+ */
|
|
|
+export class AppWebEntry {
|
|
|
+ private readonly el: HTMLElement
|
|
|
+ private readonly seams: BootSeams | undefined
|
|
|
+ private readonly status = createLoaderStatusStore()
|
|
|
+ private readonly settled = createSignal(false)
|
|
|
+ private readonly error = createSignal<string | undefined>(undefined)
|
|
|
+ // Assigned by run() before any private method or settled-gated closure reads them.
|
|
|
+ private ctx!: Context
|
|
|
+ private modules!: ClientModuleSystem
|
|
|
+ private manifest!: BootManifest
|
|
|
+ private root: Root | undefined
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Hold the mount point; all work happens in {@link run}.
|
|
|
+ * @param el - mount point (the app's #root).
|
|
|
+ * @param seams - optional module transport overrides (test environments).
|
|
|
+ */
|
|
|
+ constructor(el: HTMLElement, seams?: BootSeams) {
|
|
|
+ this.el = el
|
|
|
+ this.seams = seams
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Run the boot chain to settlement. Boot-chain failures resolve (not
|
|
|
+ * reject): the loading page stays up and renders the failure report (the
|
|
|
+ * fail-loud surface the kernel owns). Rejects only when the boot manifest
|
|
|
+ * is missing or malformed — there is nothing to boot against.
|
|
|
+ * @returns resolves once the UI settled or the failure report rendered.
|
|
|
+ */
|
|
|
+ async run(): Promise<void> {
|
|
|
+ this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)
|
|
|
+
|
|
|
+ this.modules = new ClientModuleSystem({
|
|
|
+ modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
|
|
|
+ })
|
|
|
+ // The app-shell assembly is the only shell-own module: every other graph
|
|
|
+ // row is a plugin bundle arriving through fetch (web2 single package form).
|
|
|
+ this.modules.registerStatic(APP_SHELL_ID, AppShell)
|
|
|
+ // Adoption handoff, supply side (design §4.7): register the modules
|
|
|
+ // package's own client half under its bare package name (= graph row id
|
|
|
+ // = entry name — a suffixed key would miss the statics branch and
|
|
|
+ // trigger a real fetch), and put the instance on the kernel slot the
|
|
|
+ // wrapper's apply reads to provide ctx.modules.
|
|
|
+ this.modules.registerStatic(MODULES_ID, ModulesClient)
|
|
|
+ ;(globalThis as DshWindow).__DSH_MODULES__ = this.modules
|
|
|
+
|
|
|
+ this.root = createRoot(this.el)
|
|
|
+ this.root.render(
|
|
|
+ <AppRoot
|
|
|
+ settled={this.settled}
|
|
|
+ status={this.status}
|
|
|
+ error={this.error}
|
|
|
+ renderApp={() => {
|
|
|
+ const shell = this.ctx.get('appShell')
|
|
|
+ // Unreachable after a clean settle (the app-shell entry is in every graph).
|
|
|
+ if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
|
|
+ return shell.renderApp()
|
|
|
+ }}
|
|
|
+ />,
|
|
|
+ )
|
|
|
+
|
|
|
+ // The immediately tier prefetches in parallel with Loader mounting;
|
|
|
+ // runPluginBoot awaits it before creating entries (see module comment:
|
|
|
+ // cross-package synchronous require edges need every immediately-tier
|
|
|
+ // factory registered before any materialization).
|
|
|
+ const prefetching = this.prefetchImmediateTier()
|
|
|
+ this.ctx = new Context()
|
|
|
+ try {
|
|
|
+ await this.runPluginBoot(prefetching)
|
|
|
+ this.settled.set(true)
|
|
|
+ } catch (reason) {
|
|
|
+ // Stay on the loading page; surface the sweep report (fail loud).
|
|
|
+ console.error(reason)
|
|
|
+ this.error.set(reason instanceof Error ? reason.message : String(reason))
|
|
|
}
|
|
|
}
|
|
|
- if (failures.length > 0) {
|
|
|
- throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
|
|
+
|
|
|
+ /** Unmount the shell (loading page or settled UI). */
|
|
|
+ dispose(): void {
|
|
|
+ this.root?.unmount()
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-/** Stage one: prefetch the immediately tier (factory registration only; failures defer to stage two's import). */
|
|
|
-async function prefetchImmediateTier(modules: ClientModuleLoader, graph: WebBootGraph): Promise<void> {
|
|
|
- await Promise.all(graph.entries
|
|
|
- .filter((row) => row.immediately === true)
|
|
|
- .map((row) => modules.prefetch(row.id).catch(() => {
|
|
|
- // Import (stage two) refetches and reports this loudly per entry;
|
|
|
- // swallowing here keeps one failing prefetch from masking the others.
|
|
|
- })))
|
|
|
-}
|
|
|
+ /** Prefetch the immediately tier (factory registration only; failures defer to the import path). */
|
|
|
+ private async prefetchImmediateTier(): Promise<void> {
|
|
|
+ await Promise.all(this.manifest.plugins
|
|
|
+ .filter((row) => row.immediately)
|
|
|
+ .map((row) => this.modules.prefetch(row.id).catch(() => {
|
|
|
+ // Import refetches and reports this loudly per entry; swallowing
|
|
|
+ // here keeps one failing prefetch from masking the others.
|
|
|
+ })))
|
|
|
+ }
|
|
|
|
|
|
-/** Stage two: mount the Loader, inject the internal seam, create the graph entries, settle, sweep. */
|
|
|
-async function runPluginBoot(
|
|
|
- ctx: Context, modules: ClientModuleLoader, graph: WebBootGraph, status: LoaderStatusStore,
|
|
|
-): Promise<void> {
|
|
|
- await ctx.plugin(Loader)
|
|
|
- const loader = ctx.loader
|
|
|
- // Inject the module system BEFORE any entry exists: tree.import falls back
|
|
|
- // to a bare dynamic import when internal is undefined, which in a browser
|
|
|
- // is a guaranteed loud failure — correct as a tripwire, never as a path.
|
|
|
- loader.internal = modules as never
|
|
|
-
|
|
|
- // Status projection: AppRoot displays fiber truth. Every internal/status
|
|
|
- // transition under an entry re-projects that entry's row from its ROOT
|
|
|
- // fiber (child plugin fibers share the same entry).
|
|
|
- ctx.on('internal/status', (fiber) => {
|
|
|
- const entry = fiber.entry
|
|
|
- if (entry === undefined || entry.fiber === undefined) return
|
|
|
- status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
|
|
- })
|
|
|
-
|
|
|
- // Entry creation order carries no semantics (fiber inject waiting owns
|
|
|
- // activation order); creating concurrently lets non-prefetched bundle
|
|
|
- // fetches parallelize. The app-shell assembly entry is appended by the
|
|
|
- // kernel: it is shell-own code (host graph rows are all plugin bundles),
|
|
|
- // and mounting the assembly is not a composition decision — it rides the
|
|
|
- // same entry lifecycle so the sweep and status cover it uniformly.
|
|
|
- const rows = [...graph.entries.map((row) => row.id), APP_SHELL_ID]
|
|
|
- await Promise.all(rows.map(async (name) => {
|
|
|
- status.set(name, 'loading')
|
|
|
- const id = await loader.create({ name })
|
|
|
- // A failed import leaves the entry fiberless (Entry._init logs and
|
|
|
- // returns); project it as failed — no fiber means no status event.
|
|
|
- if (loader.resolve(id).fiber === undefined) {
|
|
|
- status.set(name, 'failed')
|
|
|
- }
|
|
|
- }))
|
|
|
+ /** Plugin face: mount the Loader, inject the internal seam, adopt modules, create the graph entries, settle, sweep. */
|
|
|
+ private async runPluginBoot(prefetching: Promise<void>): Promise<void> {
|
|
|
+ const ctx = this.ctx
|
|
|
+ await ctx.plugin(Loader)
|
|
|
+ const loader = ctx.loader
|
|
|
+ // Inject the module system BEFORE any entry exists: tree.import falls back
|
|
|
+ // to a bare dynamic import when internal is undefined, which in a browser
|
|
|
+ // is a guaranteed loud failure — correct as a tripwire, never as a path.
|
|
|
+ loader.internal = this.modules as never
|
|
|
|
|
|
- await loader.await()
|
|
|
- assertEntriesActive(ctx)
|
|
|
-}
|
|
|
+ // Status projection: AppRoot displays fiber truth. Every internal/status
|
|
|
+ // transition under an entry re-projects that entry's row from its ROOT
|
|
|
+ // fiber (child plugin fibers share the same entry).
|
|
|
+ ctx.on('internal/status', (fiber) => {
|
|
|
+ const entry = fiber.entry
|
|
|
+ if (entry === undefined || entry.fiber === undefined) return
|
|
|
+ this.status.set(entry.options.name, STATE_LABELS[entry.fiber.state])
|
|
|
+ })
|
|
|
|
|
|
-/**
|
|
|
- * Mount the web shell into a DOM element and start the two-stage boot chain.
|
|
|
- * @param el - mount point (the app's #root).
|
|
|
- * @param seams - optional module transport overrides (test environments).
|
|
|
- * @returns unmount disposer.
|
|
|
- */
|
|
|
-export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
|
|
|
- const graph = (globalThis as DshWindow).__DSH_BOOT__
|
|
|
- if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
|
|
|
-
|
|
|
- const ctx = new Context()
|
|
|
- const modules = createClientModuleLoader({ graph, staticModules: getStaticModules(), ...seams })
|
|
|
- // The app-shell assembly is the only shell-own module: every other graph
|
|
|
- // row is a plugin bundle arriving through fetch (web2 single package form).
|
|
|
- modules.registerStatic(APP_SHELL_ID, AppShell)
|
|
|
- // Contract C5: the module system is a boot-owned kernel service (ctx.modules).
|
|
|
- ctx.reflect.provide('modules', modules)
|
|
|
-
|
|
|
- const status = createLoaderStatusStore()
|
|
|
- const settled = createSignal(false)
|
|
|
- const error = createSignal<string | undefined>(undefined)
|
|
|
-
|
|
|
- const root = createRoot(el)
|
|
|
- root.render(
|
|
|
- <AppRoot
|
|
|
- settled={settled}
|
|
|
- status={status}
|
|
|
- error={error}
|
|
|
- renderApp={() => {
|
|
|
- const shell = ctx.get('appShell')
|
|
|
- // Unreachable after a clean settle (the app-shell entry is in every graph).
|
|
|
- if (shell === undefined) throw new Error('web boot: appShell service missing after settled')
|
|
|
- return shell.renderApp()
|
|
|
- }}
|
|
|
- />,
|
|
|
- )
|
|
|
-
|
|
|
- prefetchImmediateTier(modules, graph)
|
|
|
- .then(() => runPluginBoot(ctx, modules, graph, status))
|
|
|
- .then(
|
|
|
- () => { settled.set(true) },
|
|
|
- (reason: unknown) => {
|
|
|
- // Stay on the loading page; surface the sweep report (fail loud).
|
|
|
- console.error(reason)
|
|
|
- error.set(reason instanceof Error ? reason.message : String(reason))
|
|
|
- },
|
|
|
- )
|
|
|
- return () => { root.unmount() }
|
|
|
+ // Barrier before any entry exists: entry creation materializes bundles,
|
|
|
+ // and materialization runs synchronous cross-package require edges that
|
|
|
+ // need every immediately-tier factory already registered (module
|
|
|
+ // comment). Resolves even when individual prefetches failed.
|
|
|
+ await prefetching
|
|
|
+
|
|
|
+ // Adoption handoff, plugin side: the modules entry is created first —
|
|
|
+ // its wrapper apply reads the kernel slot and provides ctx.modules (the
|
|
|
+ // provide lives on the plugin face; see MODULES_ID for why the row loop
|
|
|
+ // must then skip it).
|
|
|
+ const rows = [MODULES_ID, ...this.manifest.plugins.map((row) => row.id).filter((id) => id !== MODULES_ID), APP_SHELL_ID]
|
|
|
+ // Entry creation order carries no semantics (fiber inject waiting owns
|
|
|
+ // activation order); creating concurrently lets non-prefetched bundle
|
|
|
+ // fetches parallelize. The app-shell assembly entry is appended by the
|
|
|
+ // kernel: it is shell-own code (host graph rows are all plugin bundles),
|
|
|
+ // and mounting the assembly is not a composition decision — it rides the
|
|
|
+ // same entry lifecycle so the sweep and status cover it uniformly.
|
|
|
+ await Promise.all(rows.map(async (name) => {
|
|
|
+ this.status.set(name, 'loading')
|
|
|
+ const id = await loader.create({ name })
|
|
|
+ // A failed import leaves the entry fiberless (Entry._init logs and
|
|
|
+ // returns); project it as failed — no fiber means no status event.
|
|
|
+ if (loader.resolve(id).fiber === undefined) {
|
|
|
+ this.status.set(name, 'failed')
|
|
|
+ }
|
|
|
+ }))
|
|
|
+
|
|
|
+ await loader.await()
|
|
|
+ this.assertEntriesActive()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Sweep every loader entry after the tree quiesced: an entry without a
|
|
|
+ * fiber failed its import; a fiber not ACTIVE is FAILED (apply threw) or
|
|
|
+ * PENDING (a required service never arrived — cordis inject waiting has no
|
|
|
+ * timeout, so this sweep is the fail-loud compensation).
|
|
|
+ */
|
|
|
+ private assertEntriesActive(): void {
|
|
|
+ const ctx = this.ctx
|
|
|
+ const failures: string[] = []
|
|
|
+ for (const entry of ctx.loader.entries()) {
|
|
|
+ const name = entry.options.name
|
|
|
+ if (entry.fiber === undefined) {
|
|
|
+ failures.push(`${name}: import failed (see console for the import error)`)
|
|
|
+ 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}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
|
|
+ } else {
|
|
|
+ failures.push(`${name}: ${state}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (failures.length > 0) {
|
|
|
+ throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|