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
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-modules
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
}
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Client module system: the browser peer of Node's internal ESM loader, built
* as a lazy CJS table. The vendored cordis Loader consumes this object
* through its `internal` seam (the only call site is `EntryTree.import` →
* `internal.import`), which keeps entry governance (fiber lifecycle, inject
* waiting, update/refresh) entirely on the vendored side while this package
* owns code arrival.
*
* Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
* side effect — including CSS injection — lives inside the factory closure
* and runs at materialization, not at script execution. Materialization
* (factory(require) → export surface) happens on first import/require and is
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
* another registered-but-unmaterialized module materializes it recursively,
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* imports are a build error anyway.
* @module @deepseek-ai/dsh-client-modules
*/
import { ClientModuleLoaderImpl } from './loader.ts'
export { ClientModuleLoaderImpl }
declare module 'cordis' {
interface Context {
/** The client module system the web shell provides at boot (contract C5). */
modules: ClientModuleLoader
}
}
/**
* One composed client entry pushed by the host (web2 §0 graph row).
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's dshClient
* declaration and reach fibers through entry creation).
*
* Wire contract, held on both sides: the producing peer lives in
* `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace
* dependencies, so neither side imports the other's shape — drift between
* the two declarations is a bug against the web2 contract).
*/
export interface WebBootEntry {
/** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */
id: string
/**
* Bundle endpoint, '/plugins/<id>/client.js?rev=<rev>'. Absent only on
* shell-owned pseudo rows (app-shell) whose module is statically registered
* — a row that is neither fetchable nor static-registered fails loud.
*/
url?: string
/** Bundle content hash (cache-busting consistency anchor); absent with url. */
rev?: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
immediately?: boolean
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
entries: WebBootEntry[]
}
/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */
export interface ClientPluginHandoff {
/** Plugin id (package name) — the registration key; must match the graph row being executed. */
id: string
/**
* Closure factory holding the whole bundle body: receives the synchronous
* require bound to the module table and returns the bundle's export
* surface. Runs once, at materialization.
*/
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs. */
__DSH_BOOT__?: WebBootGraph
/** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
}
/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
surface: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
/** Observed `require()` edges (module-graph seam; only table words can appear today). */
edges: Set<string>
}
/**
* The internal-seam subset the vendored Loader and the client HMR plugin
* consume. Mounted on `ctx.loader.internal` by the shell boot and provided
* as `ctx.modules` (contract C5).
*/
export interface ClientModuleLoader {
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
version: 'client'
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
loadCache: Map<string, ClientModuleRecord>
/**
* Internal seam consumed by the vendored Loader's `tree.import`. Resolves
* `specifier` through the branch order documented on the module, fetching
* and executing a bundle when needed.
* @param specifier - module specifier (entry name or table word).
* @param parentURL - importer URL (unused — the client module graph is flat).
* @param attrs - import attributes (unused; interface parity with Node's seam).
* @returns the module's export surface.
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
}
/** Options for {@link createClientModuleLoader} (assembled by the web shell at boot). */
export interface ClientModuleLoaderOptions {
/** Host-composed entry graph. */
graph: WebBootGraph
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
}
/**
* Build the client module system.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
* @returns the loader the shell mounts as `ctx.loader.internal` and provides as `ctx.modules`.
*/
export function createClientModuleLoader(options: ClientModuleLoaderOptions): ClientModuleLoader {
return new ClientModuleLoaderImpl(options)
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-modules`.
* @module @deepseek-ai/dsh-client-modules/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules'
/** Cordis companion plugin name. */
export const name = 'client-modules-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the module loader is pre-plugin kernel machinery —
* it emits no cordis events (the vendored Loader owns entry lifecycle events)
* and its mutable state (loadCache, handoff slot) lives below the plugin
* layer where invariant observers cannot mount before it runs; resolve branch
* order and handoff discipline are asserted by the web boot specs against the
* real execution path.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+223
View File
@@ -0,0 +1,223 @@
/**
* ClientModuleLoaderImpl — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the package module and the public interfaces in `./index.ts`;
* this file owns the state tables and the fetch/execute/materialize machinery.
*/
import type {
ClientModuleLoader, ClientModuleLoaderOptions, ClientModuleRecord,
ClientPluginHandoff, DshWindow, WebBootEntry,
} from './index.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (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 urlOf = (row: WebBootEntry): string => {
// url is conditional on the wire (shell-own pseudo rows omit it); those
// ids resolve through the static registry and never reach a fetch.
if (row.url === undefined) throw new Error(`client-modules: entry "${row.id}" has no bundle url and no static registration`)
return row.url
}
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
* surface, so table lookups normalize the suffix away.
*/
const stripClientSuffix = (spec: string): string =>
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
* any untagged tag is claimed for the materializing plugin (HMR bookkeeping).
*/
const claimStyles = (id: string): string[] => {
if (typeof document === 'undefined') return []
for (const el of document.querySelectorAll('style:not([data-plugin])')) {
el.setAttribute('data-plugin', id)
}
const owned: string[] = []
for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(id)}]`)) {
owned.push(el.getAttribute('data-plugin-css') ?? id)
}
return owned
}
/**
* The client module system: state tables plus the arrival/materialization
* machinery implementing {@link ClientModuleLoader} (whose members carry the
* seam contract docs). Construction indexes the boot graph and installs the
* `window.__ModuleLoader__` registration sink (contract C6) — once per page.
*/
export class ClientModuleLoaderImpl implements ClientModuleLoader {
readonly version = 'client'
readonly loadCache = new Map<string, ClientModuleRecord>()
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, WebBootEntry>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
/**
* Build the module system over the host graph.
* @param options - entry graph, module-table staticModules, fetch/execute seams.
*/
constructor(options: ClientModuleLoaderOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
for (const entry of options.graph.entries) {
if (this.graphRows.has(entry.id)) throw new Error(`client-modules: duplicate graph entry "${entry.id}"`)
this.graphRows.set(entry.id, entry)
}
const win = globalThis as DshWindow
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
win.__ModuleLoader__ = {
load: (handoff: ClientPluginHandoff): void => {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: WebBootEntry): Promise<void> {
const { id } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const url = urlOf(row)
const code = await this.fetchBundle(url)
this.executingUrl = url
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
}
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
/** Materialize a registered factory (synchronous; memoized in loadCache). */
private materialize(id: string): ClientModuleRecord {
const existing = this.loadCache.get(id)
if (existing !== undefined) return existing
const registered = this.factories.get(id)
/* v8 ignore next -- callers check the factory branch before dispatching here. */
if (registered === undefined) throw new Error(`client-modules: no registered factory for "${id}"`)
if (this.materializing.has(id)) {
throw new Error(`client-modules: require cycle through "${id}" (factory-form CJS cannot deliver partial exports)`)
}
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record
} finally {
this.materializing.delete(id)
}
}
/**
* The synchronous require answered to factories: seed → static → memoized
* record → registered factory (recursive materialization — this is what
* makes load order self-resolving). Fetching is async and therefore
* unreachable from here; an unregistered plugin specifier is loud (and a
* cross-plugin value import is already a build error upstream).
*/
private makeRequire(edges: Set<string>): (spec: string) => unknown {
return (spec: string): unknown => {
edges.add(spec)
if (this.seed.has(spec)) return this.seed.get(spec)
if (this.statics.has(spec)) return this.statics.get(spec)
const id = stripClientSuffix(spec)
const record = this.loadCache.get(id)
if (record !== undefined) return record.surface
if (this.factories.has(id)) return this.materialize(id).surface
throw new Error(
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
)
}
}
async import(specifier: string): Promise<unknown> {
if (this.seed.has(specifier)) return this.seed.get(specifier)
const existing = this.loadCache.get(specifier)
if (existing !== undefined) return existing.surface
if (this.statics.has(specifier)) {
const surface = this.statics.get(specifier)
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
return surface
}
if (!this.factories.has(specifier)) {
const row = this.graphRows.get(specifier)
if (row === undefined) {
throw new Error(
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
)
}
await this.arrive(row)
}
return this.materialize(specifier).surface
}
registerStatic(id: string, module: unknown): void {
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
this.statics.set(id, module)
}
async prefetch(id: string): Promise<void> {
if (this.statics.has(id)) return
const row = this.graphRows.get(id)
if (row === undefined) throw new Error(`client-modules: prefetch("${id}") — not a graph entry`)
await this.arrive(row)
}
invalidate(id: string): void {
this.factories.delete(id)
this.loadCache.delete(id)
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}