From b58f0989f9cfbc8fa2f7218cdf7cac15f5a85d06 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:55:39 +0800 Subject: [PATCH 01/11] 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. --- apps/web/src/node-module-stub.ts | 16 + apps/web/tests/smoke-fixture.e2e.ts | 102 ++++--- apps/web/vite.config.ts | 23 +- packages/client/modules/README.md | 20 ++ packages/client/modules/package.json | 37 +++ packages/client/modules/src/index.ts | 175 +++++++++++ packages/client/modules/src/invariant.ts | 34 +++ packages/client/modules/src/loader.ts | 223 ++++++++++++++ packages/client/modules/tsconfig.json | 24 ++ packages/client/runtime/package.json | 8 +- packages/client/runtime/src/client/index.ts | 51 +--- .../client/runtime/src/client/loader/index.ts | 247 --------------- .../runtime/tests/client-loader.spec.ts | 289 ------------------ packages/client/runtime/tsconfig.json | 3 + packages/client/runtime/tsdown.config.ts | 22 +- packages/client/web/README.md | 11 +- packages/client/web/package.json | 8 +- packages/client/web/src/AppRoot.tsx | 36 ++- packages/client/web/src/app-shell.ts | 59 ++++ packages/client/web/src/app.tsx | 17 +- packages/client/web/src/boot.tsx | 201 ++++++++---- packages/client/web/src/index.ts | 14 +- packages/client/web/src/loader-status.ts | 111 +++++++ packages/client/web/src/platform.ts | 20 ++ packages/client/web/src/seed.ts | 24 +- packages/client/web/tests/app-root.spec.tsx | 51 ++-- packages/client/web/tests/boot.spec.tsx | 233 -------------- packages/client/web/tsconfig.json | 15 +- tsconfig.base.json | 3 +- tsconfig.client.json | 2 + 30 files changed, 1064 insertions(+), 1015 deletions(-) create mode 100644 apps/web/src/node-module-stub.ts create mode 100644 packages/client/modules/README.md create mode 100644 packages/client/modules/package.json create mode 100644 packages/client/modules/src/index.ts create mode 100644 packages/client/modules/src/invariant.ts create mode 100644 packages/client/modules/src/loader.ts create mode 100644 packages/client/modules/tsconfig.json delete mode 100644 packages/client/runtime/src/client/loader/index.ts delete mode 100644 packages/client/runtime/tests/client-loader.spec.ts create mode 100644 packages/client/web/src/app-shell.ts create mode 100644 packages/client/web/src/loader-status.ts create mode 100644 packages/client/web/src/platform.ts delete mode 100644 packages/client/web/tests/boot.spec.tsx diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts new file mode 100644 index 0000000000..c64f307f7c --- /dev/null +++ b/apps/web/src/node-module-stub.ts @@ -0,0 +1,16 @@ +/** + * Browser stand-in for `node:module`, mapped by the vite alias in + * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports + * `createRequire` at module scope but only calls it inside + * `ModuleLoader.fromInternal()`, whose version probe is compiled to the + * `"0.0.0"` define in the browser build — so this throw is a fail-loud + * tripwire for any path that would genuinely need Node's module machinery. + */ + +/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ +export const createRequire = (): never => { + throw new Error('node:module is not available in the browser') +} + +/** Erased type peer for the vendored loader's type-only LoadHookContext import. */ +export type LoadHookContext = never diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 9f3998932f..0726d14c8b 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,41 +1,67 @@ -// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins -// registry surface + __DSH_BOOT__ injection + built shell dist in a real -// chromium. First describe: manifest injection + static serving. Second +// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry +// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real +// chromium. First describe: graph injection + the fail-loud half. Second // describe: the settled success pass — all nine REAL tsdown bundles load -// through the DI chain in ?fixture mode, the three-column frame appears in -// one flip, and the resident question completes through the real UI stack. -// The full model round lands in smoke-real under the W5 real-host standard. +// through the module system + vendored Loader chain in ?fixture mode (the +// infrastructure four ride the immediately prefetch tier, the UI rows fetch +// on demand), the three-column frame appears in one flip, and the resident +// question completes through the real UI stack. The full model round lands +// in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { startWebServer } from '@deepseek-ai/dsh-host-webserver' -import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver' +import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver' import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts' const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) +const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' +const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar' + /** id ↔ bundle table for the success pass (the complete Web UI assembly). */ -const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, +const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true }, + { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] }, { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] -/** Manifest served by the fake registry: one live bundle row, one missing row. */ -const ROWS: WebPluginBootEntry[] = [ - { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] }, - { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] }, -] -const LAYOUT_BUNDLE = bundlePath('ui-layout') +const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) + +const row = (id: string, extra?: Partial): WebBootEntry => + ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra }) + +const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, { + ...(p.inject !== undefined ? { inject: p.inject } : {}), + ...(p.immediately === true ? { immediately: true } : {}), +})) + +/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */ +const FAIL_GRAPH: WebBootGraph = { + rev: 'e2e-fail', + entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')], +} + +/** Graph for the success pass: the complete assembly. */ +const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows } + +/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */ +function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap) { + return { + graph: () => graph, + clientPath: (id: string) => byId.get(id), + onRebuilt: () => () => undefined, + } +} describe('web boot chain (keyless, real carrier)', () => { let server: Awaited> @@ -52,10 +78,7 @@ describe('web boot chain (keyless, real carrier)', () => { port, distIndex: DIST_INDEX, apiHandler, - webPlugins: { - snapshot: () => ROWS, - clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined), - }, + webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS), }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() page = await browser.newPage() @@ -68,16 +91,25 @@ describe('web boot chain (keyless, real carrier)', () => { await server?.close() }) - it('GET / injects the manifest verbatim', async () => { + it('GET / injects the entry graph verbatim', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest')) const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__) - expect(boot).toEqual({ plugins: ROWS }) + expect(boot).toEqual(FAIL_GRAPH) }) it('serves a real bundle through the plugins endpoint', async () => { - const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`) + const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`) expect(res.status()).toBe(200) - expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin') + expect(await res.text()).toContain('window.__ModuleLoader__.load') + }) + + it('boots to the loading page and fail-louds the absent entry', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) + await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) + await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) + await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) + // The real UI must not have flipped in: the gate opens only on settled. + expect(await page.locator('[class*="frame"]').count()).toBe(0) }) it('applies the token sheets before any plugin CSS', async () => { @@ -87,7 +119,6 @@ describe('web boot chain (keyless, real carrier)', () => { }) describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => { - const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser let page: Page @@ -95,14 +126,9 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( beforeAll(async () => { requireDist() + const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter bundle): ${missing.map(m => m.dir).join(', ')}`) const port = await probeFreePort() - const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => { - const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject } - if (p.immediately === true) row.immediately = true - return row - }) - const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } server = await startWebServer({ @@ -110,7 +136,7 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( port, distIndex: DIST_INDEX, apiHandler, - webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) }, + webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS), }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() page = await browser.newPage() @@ -135,8 +161,8 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( it('every plugin CSS landed with its ownership tag', async () => { const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) - expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') - expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + expect(owners).toContain(LAYOUT_ID) + expect(owners).toContain(SIDEBAR_ID) }) it('collapsed sidebar animates to a 56px rail with the four controls', async () => { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1622371a8a..5a805cbeb3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,17 +10,28 @@ export default defineConfig({ // Workspace packages resolve to SOURCE: package.json exports point at lib // for Node/type consumers, but the browser bundle must compile src directly // so CSS rides vite's pipeline instead of the CSS-externalized lib bundle. - // Only the shell's static surface is aliased — UI plugin packages are NOT - // bundled here; they arrive as dynamic bundles through the client loader. - // Order matters — subpath aliases must win over bare-name prefixes. + // Only the shell's normal-package surface is aliased — plugin packages are + // NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime + // bundles through the client module system. Order matters — subpath + // aliases must win over bare-name prefixes. alias: [ + // Browserization of the vendored cordis Loader: its only node-only + // import; the two process probes are mapped by `define` below. + { find: /^node:module$/, replacement: src('./src/node-module-stub.ts') }, { find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') }, - { find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') }, { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') }, ], }, + define: { + // vendored loader internal.ts: fromInternal() probes the Node major — + // "0.0.0" takes neither branch, returning undefined (exactly the empty + // internal slot the shell boot fills with the client module loader). + 'process.versions.node': '"0.0.0"', + 'process.execArgv': '[]', + // vendored loader index.ts: envData falls to its default branch. + 'process.env.CORDIS_SHARED': 'undefined', + }, }) diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md new file mode 100644 index 0000000000..234b8406e6 --- /dev/null +++ b/packages/client/modules/README.md @@ -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). `/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. diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json new file mode 100644 index 0000000000..ad2fb78ab1 --- /dev/null +++ b/packages/client/modules/package.json @@ -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" + } +} diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts new file mode 100644 index 0000000000..cb22dfa7ca --- /dev/null +++ b/packages/client/modules/src/index.ts @@ -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//client.js?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 +} + +/** 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 `