refactor(gui): rebuild the client loading kernel as dsh-client-modules with a two-phase boot

The module system moves out of dsh-client-runtime (./loader retired) into
its own package: a lazy CJS table where executing a bundle only registers
its factory and materialization happens at first require, memoized, with
recursive requires self-ordering. ClientModuleSystem is a class; index.ts
keeps the types and a thin factory. Boot is two-phase: phase one prefetches
the immediately tier in parallel (registration only, failures deferred to
phase two's loud import); phase two mounts the vendored Loader with the
module system as internal, creates one entry per graph row plus the
app-shell pseudo-row the kernel appends itself, and settles on an
all-ACTIVE sweep. The shell kernel is self-sufficient: hand-rolled
loader-status stores, no plugin value imports, platform seed list single-
sourced in platform.ts.
This commit is contained in:
imccyu
2026-07-23 21:55:39 +08:00
parent 15fde82f80
commit b58f0989f9
30 changed files with 1064 additions and 1015 deletions

View File

@@ -2,17 +2,15 @@
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), the ClientLoader interface, and the cordis Context/Events
* merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from './contract/store.ts'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
@@ -95,48 +93,9 @@ declare module 'cordis' {
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
loader: ClientLoader
}
}
/** One __DSH_BOOT__ manifest row. */
export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
/** Per-plugin load status store shape. */
export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
/**
* Client bundle loader. The immediately group loads first (parallel fetch,
* apply in inject topology order); remaining plugins follow in inject
* topology. Loaded bundle export surfaces are registered back into the
* require module table. Implementation lives in the `./loader` subpath
* (shell-held machinery).
*/
export interface ClientLoader {
/** Start loading from window.__DSH_BOOT__ (non-blocking). */
start(): void
/**
* Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
* @param id - plugin id (package name).
*/
load(id: string): Promise<void>
/**
* Unload a plugin. P-I: not implemented (full chain lands with HMR).
* @param id - plugin id.
*/
unload(id: string): Promise<void>
/** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
settled(): Promise<void>
/**
* Read a loaded module's export surface from the module table (same
* implementation the bundle-facing require uses; unknown spec throws).
* @param spec - module specifier (package name or seeded library id).
*/
requireModule(spec: string): unknown
/** Per-plugin status store. */
readonly status: SnapshotStore<LoaderStatus>
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']

View File

@@ -1,247 +0,0 @@
/**
* ClientLoader implementation (shell-held machinery — the loader cannot load
* itself, so the web shell imports this subpath statically and mounts the
* instance as ctx.loader; the runtime package's own client bundle never
* includes it).
*
* Load chain per plugin: fetch bundle text → execute (script injection) → the
* bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
* handoff, id reconciled) → factory(require) with require bound to the module
* table → ctx.plugin(exports.apply) → the export surface is registered into
* the module table under the plugin id (inject topology guarantees later
* loaders can require earlier ones) → <style data-plugin> ownership recorded.
*
* start(): the `immediately` group is fetched in parallel and executed in
* group-internal inject topology (execution is serial — the handoff slot is
* single); a full-group barrier precedes the remaining plugins, which then
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '../contract/store.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
export interface ClientPluginHandoff {
/** Plugin id (package name) — must match the manifest row being loaded. */
id: string
/**
* Closure factory: receives the DI require and returns the module's export
* surface; an `apply` export is applied as a cordis plugin.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface the loader owns (bundle side of the handoff protocol). */
interface DshWindow {
__DSH_BOOT__?: { plugins: BootPluginEntry[] }
DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
}
/** Options for createClientLoader (assembled by the web shell at boot). */
export interface ClientLoaderOptions {
/** Client root context: plugin applies mount under it. */
ctx: Context
/**
* Seeded module table: pure-library entities (react, react-dom, cordis,
* ui-slots, web-react, ui-primitives). The loader takes ownership and
* registers loaded bundle export surfaces alongside them.
*/
modules: Record<string, unknown>
/**
* Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
* same protocol shape.
*/
boot?: { plugins: BootPluginEntry[] }
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (serial half; execution synchronously performs the
* loadPlugin handoff). Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/** Per-plugin bookkeeping across the load chain. */
interface PluginRecord {
entry: BootPluginEntry
state: 'idle' | 'loading' | 'active' | 'failed'
fetch?: Promise<string>
load?: Promise<void>
}
const NOT_LOADED = Symbol('dsh.loader.not-loaded')
/**
* Build the client bundle loader.
* @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
* @returns the ClientLoader the shell mounts as ctx.loader.
*/
export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
const { ctx } = options
const win = globalThis as DshWindow
const boot = options.boot ?? win.__DSH_BOOT__
if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
const modules = new Map<string, unknown>(Object.entries(options.modules))
const records = new Map<string, PluginRecord>()
for (const entry of boot.plugins) {
if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
records.set(entry.id, { entry, state: 'idle' })
}
const status = createSnapshotStore<LoaderStatus>({})
const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
status.update((draft) => { draft[id] = state })
}
// Single-slot handoff: bundle execution synchronously calls loadPlugin;
// doLoad arms the slot before executing and reconciles the id after.
let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
win.DSHClientProxy = {
loadPlugin: (handoff: ClientPluginHandoff): void => {
if (slot !== NOT_LOADED) {
throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
}
slot = handoff
},
}
const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
})
const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
})
const requireModule = (spec: string): unknown => {
if (!modules.has(spec)) {
throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
}
return modules.get(spec)
}
/** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
const claimStyles = (id: string): void => {
if (typeof document === 'undefined') return
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
}
/** Start (or reuse) the parallelizable fetch half. */
const prefetch = (record: PluginRecord): Promise<string> =>
(record.fetch ??= fetchBundle(record.entry.url))
async function doLoad(record: PluginRecord): Promise<void> {
const { id } = record.entry
record.state = 'loading'
publish(id, 'loading')
try {
// Dependencies must already be active (start() sequences this; direct
// load() callers get the same fail-loud check).
for (const dep of record.entry.inject) {
const depRecord = records.get(dep)
if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
}
const code = await prefetch(record)
executeBundle(code, record.entry.url)
if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
const handoff = slot
slot = NOT_LOADED
if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
const exports = handoff.factory(requireModule)
if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
// The whole export surface is the plugin: cordis object-plugin form
// keeps the bundle's exported `inject`/`name` (an apply-only pass would
// silently drop the dependency declaration — postmortem 0001).
const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
await fiber.await()
// Register under both specifier forms bundles emit: the bare package
// name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
// form) — the loaded surface IS the client half either way.
modules.set(id, exports)
modules.set(`${id}/client`, exports)
claimStyles(id)
record.state = 'active'
publish(id, 'active')
} catch (error) {
record.state = 'failed'
publish(id, 'failed')
throw error
}
}
const load = (id: string): Promise<void> => {
const record = records.get(id)
if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
record.load ??= doLoad(record)
return record.load
}
/** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
const topo = (ids: string[]): string[] => {
const pool = new Set(ids)
const ordered: string[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const visit = (id: string): void => {
if (done.has(id)) return
if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
visiting.add(id)
const record = records.get(id)
/* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
for (const dep of record.entry.inject) {
if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
if (pool.has(dep)) visit(dep)
}
visiting.delete(id)
done.add(id)
ordered.push(id)
}
for (const id of ids) visit(id)
return ordered
}
let settledPromise: Promise<void> | undefined
async function run(): Promise<void> {
const all = [...records.values()]
const early = all.filter(r => r.entry.immediately === true)
const rest = all.filter(r => r.entry.immediately !== true)
// Early group: parallel fetch (all requests in flight at once), serial
// inject-topology execution, full-group barrier before anything else.
const earlyOrder = topo(early.map(r => r.entry.id))
for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
for (const id of earlyOrder) await load(id)
// Remaining plugins: one by one in inject topology.
for (const id of topo(rest.map(r => r.entry.id))) await load(id)
}
return {
start: () => {
settledPromise ??= run()
// Failures surface through settled()/status — start() itself is fire-and-forget.
settledPromise.catch(() => {})
},
load,
unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
settled: () => {
if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
return settledPromise
},
requireModule,
status,
}
}