mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
16
apps/web/src/node-module-stub.ts
Normal file
16
apps/web/src/node-module-stub.ts
Normal file
@@ -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
|
||||
@@ -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>): 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<string, string>) {
|
||||
return {
|
||||
graph: () => graph,
|
||||
clientPath: (id: string) => byId.get(id),
|
||||
onRebuilt: () => () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('web boot chain (keyless, real carrier)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
@@ -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<ReturnType<typeof startWebServer>>
|
||||
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 <pkg> 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 () => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
20
packages/client/modules/README.md
Normal file
20
packages/client/modules/README.md
Normal 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
packages/client/modules/package.json
Normal file
37
packages/client/modules/package.json
Normal 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
packages/client/modules/src/index.ts
Normal file
175
packages/client/modules/src/index.ts
Normal 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
packages/client/modules/src/invariant.ts
Normal file
34
packages/client/modules/src/invariant.ts
Normal 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
packages/client/modules/src/loader.ts
Normal file
223
packages/client/modules/src/loader.ts
Normal 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
packages/client/modules/tsconfig.json
Normal file
24
packages/client/modules/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
|
||||
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -15,10 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./loader": {
|
||||
"types": "./lib/types/client/loader/index.d.ts",
|
||||
"default": "./lib/loader.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
@@ -37,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
@@ -56,7 +53,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/loader.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
|
||||
* with export-surface re-registration, immediately-group barrier (parallel
|
||||
* fetch / topology execution / full-group barrier), status store, settled,
|
||||
* failure modes (missing handoff, unknown dep, cycle, unload stub).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createClientLoader } from '../src/client/loader/index.ts'
|
||||
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
|
||||
|
||||
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
|
||||
const win = globalThis as Win
|
||||
|
||||
afterEach(() => {
|
||||
delete win.DSHClientProxy
|
||||
delete win.__DSH_BOOT__
|
||||
})
|
||||
|
||||
interface FakeBundle {
|
||||
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
loader: ReturnType<typeof createClientLoader>
|
||||
fetched: string[]
|
||||
executed: string[]
|
||||
fetchGate: Map<string, () => void>
|
||||
}
|
||||
|
||||
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
|
||||
function bench(
|
||||
plugins: BootPluginEntry[],
|
||||
bundles: Record<string, FakeBundle>,
|
||||
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
|
||||
): Bench {
|
||||
const ctx = new Context()
|
||||
const fetched: string[] = []
|
||||
const executed: string[] = []
|
||||
const fetchGate = new Map<string, () => void>()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: opts.modules ?? { react: { marker: 'react' } },
|
||||
boot: { plugins },
|
||||
fetchBundle: (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
executed.push(code)
|
||||
const bundle = bundles[code]
|
||||
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
|
||||
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
|
||||
if (typeof bundle.handoff === 'function') {
|
||||
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
|
||||
return
|
||||
}
|
||||
win.DSHClientProxy?.loadPlugin(bundle.handoff)
|
||||
},
|
||||
})
|
||||
return { loader, fetched, executed, fetchGate }
|
||||
}
|
||||
|
||||
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
|
||||
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
|
||||
|
||||
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
|
||||
handoff: require => ({
|
||||
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
|
||||
require,
|
||||
...exports,
|
||||
}),
|
||||
})
|
||||
|
||||
describe('load chain', () => {
|
||||
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
|
||||
const applied: string[] = []
|
||||
const b = bench(
|
||||
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
|
||||
{
|
||||
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
|
||||
'/plugins/feature/client.js': {
|
||||
handoff: (require) => {
|
||||
// Later loader requires the earlier one's export surface (inject topology guarantee).
|
||||
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
|
||||
const base = require(fakeBase) as { helper: string }
|
||||
expect(base.helper).toBe('base-helper')
|
||||
expect((require('react') as { marker: string }).marker).toBe('react')
|
||||
return { apply: () => { applied.push('feature') } }
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(applied).toEqual(['fake-base', 'feature'])
|
||||
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
|
||||
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
|
||||
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
|
||||
})
|
||||
|
||||
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
|
||||
const b = bench(
|
||||
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
|
||||
{
|
||||
'/plugins/a/client.js': okBundle(),
|
||||
'/plugins/b/client.js': okBundle(),
|
||||
'/plugins/later/client.js': okBundle(),
|
||||
},
|
||||
{ gated: ['/plugins/a/client.js'] },
|
||||
)
|
||||
b.loader.start()
|
||||
await Promise.resolve()
|
||||
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
|
||||
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
|
||||
expect(b.executed).toEqual([])
|
||||
b.fetchGate.get('/plugins/a/client.js')?.()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
|
||||
})
|
||||
|
||||
it('orders execution by inject topology within each group', async () => {
|
||||
const b = bench(
|
||||
[entry('z-ui', ['a-base']), entry('a-base')],
|
||||
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
|
||||
)
|
||||
b.loader.start()
|
||||
await b.loader.settled()
|
||||
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure modes (fail loud)', () => {
|
||||
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
|
||||
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
|
||||
expect(b.loader.status.getSnapshot().silent).toBe('failed')
|
||||
})
|
||||
|
||||
it('rejects on manifest/handoff id mismatch', async () => {
|
||||
const b = bench([entry('expected')], {
|
||||
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
|
||||
})
|
||||
b.loader.start()
|
||||
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
|
||||
})
|
||||
|
||||
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
|
||||
// Sequential benches: each loader owns the window proxy, so release it between them.
|
||||
const fresh = <T>(build: () => T): T => {
|
||||
delete win.DSHClientProxy
|
||||
return build()
|
||||
}
|
||||
|
||||
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
|
||||
missing.loader.start()
|
||||
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
|
||||
|
||||
const cyclic = fresh(() => bench(
|
||||
[entry('p', ['q']), entry('q', ['p'])],
|
||||
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
|
||||
))
|
||||
cyclic.loader.start()
|
||||
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
|
||||
|
||||
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
|
||||
applyless.loader.start()
|
||||
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
|
||||
|
||||
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
|
||||
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
|
||||
|
||||
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
|
||||
})
|
||||
|
||||
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
|
||||
const b = bench([], {})
|
||||
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
|
||||
// First bench installed the proxy; a second loader must refuse.
|
||||
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
|
||||
})
|
||||
|
||||
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
|
||||
const b = bench(
|
||||
[entry('dep', [], true), entry('needy', ['dep'])],
|
||||
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
|
||||
)
|
||||
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
|
||||
})
|
||||
|
||||
it('direct load() naming an unknown inject target fails loud', async () => {
|
||||
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
|
||||
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
|
||||
})
|
||||
|
||||
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
|
||||
// The fire-and-forget prefetch swallow arm must absorb the early
|
||||
// rejection; the awaited load surfaces the same failure via settled().
|
||||
const ctx = new Context()
|
||||
delete win.DSHClientProxy
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
|
||||
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
|
||||
executeBundle: () => {},
|
||||
})
|
||||
loader.start()
|
||||
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
|
||||
})
|
||||
|
||||
it('unload is the P-I stub', async () => {
|
||||
const b = bench([], {})
|
||||
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM default seams (stubbed globals)', () => {
|
||||
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
|
||||
const origFetch = globalThis.fetch
|
||||
const appended: { textContent?: string | null }[] = []
|
||||
const styleTag = {
|
||||
attrs: {} as Record<string, string>,
|
||||
setAttribute(k: string, v: string) { this.attrs[k] = v },
|
||||
}
|
||||
const fakeDoc = {
|
||||
createElement: () => {
|
||||
const el = { textContent: null as string | null }
|
||||
return el
|
||||
},
|
||||
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
|
||||
querySelectorAll: () => [styleTag],
|
||||
}
|
||||
const g = globalThis as { document?: unknown; fetch: typeof fetch }
|
||||
g.document = fakeDoc
|
||||
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
|
||||
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
|
||||
? new Response('x', { status: 500 })
|
||||
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
|
||||
)
|
||||
try {
|
||||
delete win.DSHClientProxy
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({
|
||||
ctx,
|
||||
modules: {},
|
||||
boot: { plugins: [
|
||||
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
|
||||
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
|
||||
] },
|
||||
// NO seams injected (keys omitted, not undefined — exactOptional):
|
||||
// the DOM defaults are under test.
|
||||
})
|
||||
const seamHandoff: ClientPluginHandoff = {
|
||||
id: 'seam-ok',
|
||||
factory: () => ({ apply: () => {} }),
|
||||
}
|
||||
// Default executeBundle only APPENDS the script element (no execution in
|
||||
// our fake DOM), so drive the handoff manually before load resolves it.
|
||||
const loadOk = loader.load('seam-ok')
|
||||
await Promise.resolve()
|
||||
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
|
||||
await loadOk
|
||||
expect(appended).toHaveLength(1)
|
||||
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
|
||||
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
|
||||
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
|
||||
} finally {
|
||||
g.fetch = origFetch
|
||||
delete (globalThis as { document?: unknown }).document
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff slot protocol', () => {
|
||||
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
|
||||
delete win.DSHClientProxy
|
||||
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
|
||||
const proxy = (globalThis as Win).DSHClientProxy
|
||||
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
|
||||
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
|
||||
.toThrow(/overlapping loadPlugin handoff/)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
/**
|
||||
* Standard dual-entry shape plus the loader lib half: exports["./loader"]
|
||||
* promises lib/loader.js (the web shell statically imports the machinery —
|
||||
* a loader cannot load itself), and the shared preset only emits
|
||||
* lib/{index,invariant}.js, so the extra config supplies it.
|
||||
*/
|
||||
const configs = clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
const loaderLib: UserConfig = {
|
||||
entry: { loader: 'lib/types/client/loader/index.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}
|
||||
|
||||
export default [...configs, loaderLib]
|
||||
export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# @deepseek-ai/dsh-client-web
|
||||
|
||||
Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader machinery (statically held; a loader cannot load itself), pure-library module-table seeding, AppRoot (boot loading page → settled → full UI in one switch), and the SessionProvider/scopedSlots assembly closure. The vite application entry lives in apps/web and only calls `bootWebShell`. Contract: api-contracts v3 §9.3.
|
||||
Web shell kernel: `bootWebShell(el, seams?)` mounts the whole client through the two-stage boot (web2). Stage one (module face): build the client module system (`@deepseek-ai/dsh-client-modules`) over the host-pushed entry graph (`window.__DSH_BOOT__`) and prefetch the `immediately` tier in parallel — bundle execution registers factories only. Stage two (plugin face): mount the vendored cordis Loader with the module system injected as its `internal` seam, create one loader entry per graph row plus the shell-own app-shell assembly entry (tree.import materializes each module), and gate AppRoot on the settle (loader quiesced + every entry fiber ACTIVE → full UI in one switch). Composition is entirely the host graph's: the roster and the immediately tier live in the composing app; the shell makes zero composition decisions.
|
||||
|
||||
The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin package — the boot status store and signals are hand-rolled here (`loader-status.ts`), so the loading page works while (and especially when) plugins fail. The app-shell assembly (`@deepseek-ai/dsh-client-app-shell`, a shell-owned pseudo entry with no npm package behind it) is the only module registered through `registerStatic`; it inject-waits on slots/sessions/layout like any plugin.
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
|
||||
|
||||
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
|
||||
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
|
||||
|
||||
@@ -16,6 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One-shot rendering by design** — the UI waits for `loader.settled()`; a single plugin failure keeps the loading page with a loud error, no partial availability (progressive rendering returns with its own project).
|
||||
- **No HMR** — the dev loop is tsdown watch + manual refresh for plugins; vite serves only the shell.
|
||||
- **One-shot rendering by design** — the UI waits for the boot settle; a single entry failure keeps the loading page with a loud per-entry report, no partial availability (progressive rendering returns with its own project).
|
||||
- **Narrow-window acceptance is deferred** — the concession chain is implemented in ui-layout but the shell-level narrow-viewport walkthrough is a P-II acceptance item.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web",
|
||||
"description": "Web shell library: bootWebShell (loader holding + module-table seeding + AppRoot gate + plugin assembly), consumed by the apps/web vite entry",
|
||||
"description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -20,8 +20,7 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
@@ -30,6 +29,8 @@
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
/**
|
||||
* Shell root: boot loading page → (loader settled) → real UI in one switch.
|
||||
* Pure shell component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself; the real UI is produced by the boot assembly closure
|
||||
* (renderApp) once every plugin is active. A failed plugin keeps the loading
|
||||
* page and lists the failures (fail loud, no partial UI).
|
||||
* Shell root: boot loading page → (boot settled) → real UI in one switch.
|
||||
* Pure kernel component with zero plugin dependencies — before settled it may
|
||||
* only rely on itself (the fail-loud presentation must not depend on the
|
||||
* system whose failure it reports; the status/signal stores are kernel-own,
|
||||
* web2 shell self-sufficiency rule); the real UI is produced by the
|
||||
* app-shell entry once every entry is active. A failed boot keeps the
|
||||
* loading page, lists the per-entry fiber states and the sweep report (fail
|
||||
* loud, no partial UI).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { KernelSignal, LoaderStatus } from './loader-status.ts'
|
||||
import css from './AppRoot.module.css'
|
||||
|
||||
/** AppRoot props: settled signal, loader status feed, deferred real-UI factory. */
|
||||
/** AppRoot props: settled signal, fiber-state projection feed, boot failure report, deferred real-UI factory. */
|
||||
export interface AppRootProps {
|
||||
/** True once loader.settled() resolved (the boot closure flips it; status-derived guesses race an incrementally filled table). */
|
||||
settled: ObservableSnapshot<boolean>
|
||||
/** Loader per-plugin status store (drives loading/failed rendering). */
|
||||
status: SnapshotStore<LoaderStatus>
|
||||
/** True once the boot chain settled (loader quiesced + all entries ACTIVE); the boot closure flips it. */
|
||||
settled: KernelSignal<boolean>
|
||||
/** Per-entry fiber-state projection store (drives loading/failed rendering). */
|
||||
status: KernelSignal<LoaderStatus>
|
||||
/** Boot failure report (the settle rejection message); undefined while loading or after success. */
|
||||
error: KernelSignal<string | undefined>
|
||||
/** Builds the real UI; called only after settled. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
/** Boot gate: loading page until the loader settles; failures stay here. */
|
||||
/** Boot gate: loading page until the boot settles; failures stay here. */
|
||||
export function AppRoot(props: AppRootProps) {
|
||||
const settled = useSyncExternalStore(props.settled.subscribe, props.settled.getSnapshot)
|
||||
const status = useSyncExternalStore(props.status.subscribe, props.status.getSnapshot)
|
||||
const error = useSyncExternalStore(props.error.subscribe, props.error.getSnapshot)
|
||||
const failed = Object.entries(status).filter(([, s]) => s === 'failed')
|
||||
|
||||
if (settled) return <>{props.renderApp()}</>
|
||||
|
||||
const loud = error !== undefined || failed.length > 0
|
||||
|
||||
return (
|
||||
<div className={css.boot}>
|
||||
<div className={css.card}>
|
||||
<div className={css.wordmark}>HARNESS</div>
|
||||
{failed.length === 0
|
||||
{!loud
|
||||
? (
|
||||
<>
|
||||
<div className={css.spinner} />
|
||||
@@ -44,6 +51,7 @@ export function AppRoot(props: AppRootProps) {
|
||||
<div className={css.failed}>
|
||||
<div className={css.failedTitle}>Failed to load plugins</div>
|
||||
{failed.map(([id]) => <div key={id} className={css.failedItem}>{id}</div>)}
|
||||
{error !== undefined && <div className={css.failedItem}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
59
packages/client/web/src/app-shell.ts
Normal file
59
packages/client/web/src/app-shell.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
|
||||
* responsibility, packaged as a normal static-arrival entry so the host graph
|
||||
* stays the single composition authority. It rides the same entry lifecycle
|
||||
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
|
||||
* time apply runs the layout entry is mounted and its export surface is
|
||||
* readable from the governance side (module loadCache, design §2.6).
|
||||
*
|
||||
* The pseudo package id exists only in the host graph and the shell's static
|
||||
* registry; there is no npm package behind it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
|
||||
/** Shell-owned pseudo entry id under which the host graph mounts this plugin. */
|
||||
export const APP_SHELL_ID = '@deepseek-ai/dsh-client-app-shell'
|
||||
|
||||
/** The assembled-UI face AppRoot renders once the boot settles. */
|
||||
export interface AppShellService {
|
||||
/** Build (once) and render the real UI tree. */
|
||||
renderApp: () => ReactNode
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The shell assembly face, provided by the app-shell entry once its inject set is active. */
|
||||
appShell: AppShellService
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'app-shell'
|
||||
|
||||
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/**
|
||||
* Plugin body: install the React renderer into the slot system and provide
|
||||
* the renderApp face (one ctx-level renderSlot('root') call).
|
||||
* @param ctx - plugin context (inject set active).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The renderer install is shell territory (web-react is shell-bundled),
|
||||
// but ctx.slots exists only once the runtime entry is active — so it lands
|
||||
// here, on the entry whose inject set guarantees that ordering.
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
|
||||
// Assemble once on first render: the closure must be identity-stable
|
||||
// across AppRoot re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
ctx.reflect.provide('appShell', {
|
||||
renderApp: (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx })
|
||||
return renderApp()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Real-UI assembly closure. Runs only after loader.settled(): the whole
|
||||
* layout tree hangs off the built-in 'root' slot (ui-layout registers
|
||||
* AppFrame there and renders the child slots internally) — the shell's
|
||||
* render is the one ctx-level renderSlot call in the program.
|
||||
* Real-UI assembly closure, invoked by the app-shell plugin once its inject
|
||||
* set is active: the whole layout tree hangs off the built-in 'root' slot
|
||||
* (ui-layout registers AppFrame there and renders the child slots
|
||||
* internally) — the shell's render is the one ctx-level renderSlot call in
|
||||
* the program.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -12,16 +13,14 @@ import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
|
||||
/** Assembly inputs: the active app-shell plugin ctx (slots/sessions/layout services provided). */
|
||||
export interface AssemblyDeps {
|
||||
/** Client root context (all plugin services provided). */
|
||||
/** Client context with the assembly's inject set active. */
|
||||
ctx: Context
|
||||
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
|
||||
requireModule: (spec: string) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the renderApp factory handed to AppRoot.
|
||||
* Build the renderApp factory the app-shell plugin provides to AppRoot.
|
||||
* @param deps - assembly inputs.
|
||||
* @returns factory producing the real UI tree (called once per AppRoot render after settled).
|
||||
*/
|
||||
|
||||
@@ -1,73 +1,172 @@
|
||||
/**
|
||||
* Web shell boot — the library face consumed by the apps/web entry (api
|
||||
* contracts v3 §0.3/§9.3): root ctx → hold the loader machinery (statically
|
||||
* imported; the loader cannot load itself) → seed the module table → render
|
||||
* the AppRoot loading page → loader.start() → await settled() → flip the
|
||||
* settled signal so AppRoot switches to the real UI in one pass. Load
|
||||
* failures reject settled(); AppRoot stays on the loading page listing them
|
||||
* (fail loud).
|
||||
* 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
|
||||
* value-imports a plugin package (web2 shell self-sufficiency rule: the
|
||||
* loading page must work while — especially when — plugins fail).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Composition lives in the host graph; the shell makes zero composition
|
||||
* decisions (the app-shell assembly is itself a graph entry, the only
|
||||
* shell-own module registered with the module system).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
|
||||
import {
|
||||
createClientModuleLoader,
|
||||
type ClientModuleLoader, type ClientModuleLoaderOptions, type DshWindow, type WebBootGraph,
|
||||
} from '@deepseek-ai/dsh-client-modules'
|
||||
import * as AppShell from './app-shell.ts'
|
||||
import { APP_SHELL_ID } from './app-shell.ts'
|
||||
import { AppRoot } from './AppRoot.tsx'
|
||||
import { buildRenderApp } from './app.tsx'
|
||||
import { seedModules } from './seed.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import {
|
||||
STATE_LABELS, createLoaderStatusStore, createSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Manually flipped settled signal (AppRoot's gate; see AppRootProps.settled). */
|
||||
function settledSignal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => {
|
||||
value = true
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleLoaderOptions, '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).
|
||||
*/
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
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')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientLoaderOptions, 'fetchBundle' | 'executeBundle'>
|
||||
/** 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.
|
||||
})))
|
||||
}
|
||||
|
||||
/** 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')
|
||||
}
|
||||
}))
|
||||
|
||||
await loader.await()
|
||||
assertEntriesActive(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the web shell into a DOM element and start the plugin load chain.
|
||||
* 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 loader transport overrides (test environments).
|
||||
* @param seams - optional module transport overrides (test environments).
|
||||
* @returns unmount disposer.
|
||||
*/
|
||||
export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
|
||||
const ctx = new Context()
|
||||
const loader = createClientLoader({ ctx, modules: seedModules(), ...seams })
|
||||
ctx.reflect.provide('loader', loader)
|
||||
const graph = (globalThis as DshWindow).__DSH_BOOT__
|
||||
if (graph === undefined) throw new Error('web boot: no entry graph (window.__DSH_BOOT__ missing)')
|
||||
|
||||
const settled = settledSignal()
|
||||
// Assemble once on first post-settled render: SessionProvider and the slot
|
||||
// closures must be identity-stable across re-renders.
|
||||
let renderApp: (() => ReactNode) | undefined
|
||||
const renderAppOnce = (): ReactNode => {
|
||||
renderApp ??= buildRenderApp({ ctx, requireModule: (spec) => loader.requireModule(spec) })
|
||||
return renderApp()
|
||||
}
|
||||
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={loader.status} renderApp={renderAppOnce} />)
|
||||
|
||||
loader.start()
|
||||
loader.settled().then(
|
||||
() => {
|
||||
// The renderer install is a shell-boot act, but ctx.slots exists only
|
||||
// once the runtime plugin loaded — so it lands here, after settled and
|
||||
// before the flip that lets renderApp call renderSlot('root').
|
||||
ctx.slots.install(createSlotRenderer())
|
||||
settled.flip()
|
||||
},
|
||||
() => { /* stay on the loading page; failures render from loader.status */ },
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Web shell library entry. The shell's product is {@link bootWebShell} —
|
||||
* apps/web's vite entry calls it against #root; everything else (AppRoot
|
||||
* gate, assembly closure, module-table seed) is internal to the boot chain.
|
||||
* gate, app-shell assembly entry, module-table staticModules, platform constants) is
|
||||
* internal to the boot chain. PLATFORM_MODULES is re-exported as the C1
|
||||
* single source of truth for the tsdown client externals projection.
|
||||
* @module @deepseek-ai/dsh-client-web
|
||||
*/
|
||||
|
||||
export { bootWebShell } from './boot.tsx'
|
||||
export { bootWebShell, type BootSeams } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
export { seedModules } from './seed.ts'
|
||||
export { APP_SHELL_ID, type AppShellService } from './app-shell.ts'
|
||||
export { getStaticModules } from './seed.ts'
|
||||
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
|
||||
export {
|
||||
STATE_LABELS, FIBER_STATE, createSignal, createLoaderStatusStore,
|
||||
type LoaderStatus, type LoaderEntryState, type KernelSignal, type KernelValueSignal, type LoaderStatusStore,
|
||||
} from './loader-status.ts'
|
||||
|
||||
111
packages/client/web/src/loader-status.ts
Normal file
111
packages/client/web/src/loader-status.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Fiber-state projection vocabulary and the kernel-owned status store for the
|
||||
* boot loading page. The status AppRoot renders is a projection of the real
|
||||
* cordis fiber states (display the truth, not a retelling) — the boot chain
|
||||
* subscribes `internal/status` and recomputes one row per loader entry.
|
||||
*
|
||||
* The store is hand-rolled here because of the shell self-sufficiency rule
|
||||
* (web2 §0): the snapshot-store machinery lives in the runtime PLUGIN
|
||||
* package, and the shell kernel must not value-import any plugin package —
|
||||
* the loading page has to work while (and especially when) plugins fail.
|
||||
* @module @deepseek-ai/dsh-client-web/src/loader-status
|
||||
*/
|
||||
import type { FiberState } from 'cordis'
|
||||
|
||||
/**
|
||||
* Value mirror of cordis's `FiberState` const enum: a const enum has no
|
||||
* runtime object to import (and esbuild-based pipelines cannot inline it
|
||||
* across modules), so these values mirror the pinned vendored definition
|
||||
* while retaining its type (same rationale as dsh-tool-cordis's mirror).
|
||||
*/
|
||||
export const FIBER_STATE = {
|
||||
PENDING: 0 as FiberState.PENDING,
|
||||
LOADING: 1 as FiberState.LOADING,
|
||||
ACTIVE: 2 as FiberState.ACTIVE,
|
||||
FAILED: 3 as FiberState.FAILED,
|
||||
DISPOSED: 4 as FiberState.DISPOSED,
|
||||
UNLOADING: 5 as FiberState.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** One entry's projected state label (lower-case face of {@link FiberState}). */
|
||||
export type LoaderEntryState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
||||
|
||||
/** Label for each fiber state, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS: Record<FiberState, LoaderEntryState> = {
|
||||
[FIBER_STATE.PENDING]: 'pending',
|
||||
[FIBER_STATE.LOADING]: 'loading',
|
||||
[FIBER_STATE.ACTIVE]: 'active',
|
||||
[FIBER_STATE.FAILED]: 'failed',
|
||||
[FIBER_STATE.DISPOSED]: 'disposed',
|
||||
[FIBER_STATE.UNLOADING]: 'unloading',
|
||||
}
|
||||
|
||||
/** Per-entry state projection (AppRoot's status feed), keyed by entry name. */
|
||||
export type LoaderStatus = Record<string, LoaderEntryState>
|
||||
|
||||
/** Minimal observable snapshot the kernel components consume (useSyncExternalStore shape). */
|
||||
export interface KernelSignal<T> {
|
||||
/** Current value (stable reference between changes). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to changes.
|
||||
* @param fn - change listener.
|
||||
* @returns the unsubscribe disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/** Writable one-value signal (settled flag, boot failure report). */
|
||||
export interface KernelValueSignal<T> extends KernelSignal<T> {
|
||||
/**
|
||||
* Publish a new value and notify subscribers.
|
||||
* @param next - the new value.
|
||||
*/
|
||||
set(next: T): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a writable kernel signal.
|
||||
* @param init - initial value.
|
||||
* @returns the signal.
|
||||
*/
|
||||
export function createSignal<T>(init: T): KernelValueSignal<T> {
|
||||
let value = init
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (next) => {
|
||||
value = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The boot status store: per-entry rows over a {@link KernelSignal} face. */
|
||||
export interface LoaderStatusStore extends KernelSignal<LoaderStatus> {
|
||||
/**
|
||||
* Project one entry's state (copy-on-write so getSnapshot references only
|
||||
* change on writes — useSyncExternalStore contract).
|
||||
* @param id - entry name.
|
||||
* @param state - projected fiber state.
|
||||
*/
|
||||
set(id: string, state: LoaderEntryState): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the boot status store.
|
||||
* @returns the store (empty until the boot chain projects rows).
|
||||
*/
|
||||
export function createLoaderStatusStore(): LoaderStatusStore {
|
||||
let value: LoaderStatus = {}
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
set: (id, state) => {
|
||||
value = { ...value, [id]: state }
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/client/web/src/platform.ts
Normal file
20
packages/client/web/src/platform.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Platform singletons the shell shares into the module table.
|
||||
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
|
||||
* client externals = the shared surface. The three projections import this
|
||||
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
|
||||
* external judgement (packages/client/tsdown.client.ts), and the vite alias
|
||||
* check — so the list cannot drift between them.
|
||||
* @module @deepseek-ai/dsh-client-web/src/platform
|
||||
*/
|
||||
|
||||
/** The module specifiers the shell shares into the frozen module table. */
|
||||
export const PLATFORM_MODULES = [
|
||||
'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
] as const
|
||||
|
||||
/** One platform module specifier (a seed-table key). */
|
||||
export type PlatformModule = (typeof PLATFORM_MODULES)[number]
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Pure-library module-table seed. These are the ONLY entities statically
|
||||
* built into the shell bundle besides the loader machinery — every plugin
|
||||
* (including the infrastructure four) arrives as a dynamic bundle and
|
||||
* resolves its externals against this table through the loader's require.
|
||||
* Keys must match the tsdown client preset's external specifiers
|
||||
* (packages/client/tsdown.client.ts CLIENT_EXTERNALS ∩ pure libraries).
|
||||
* Platform-singleton module-table. These are the ONLY entities the shell
|
||||
* shares into the frozen module table — fetch bundles resolve their externals
|
||||
* against exactly this set through the loader's require. Keys come from the
|
||||
* platform constant module ({@link ./platform.ts}, contract C1: single source
|
||||
* of truth with the tsdown client externals); values stay shell-static
|
||||
* imports so every bundle sees the same instance.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import * as ReactJsxRuntime from 'react/jsx-runtime'
|
||||
@@ -14,12 +14,16 @@ import * as Cordis from 'cordis'
|
||||
import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import * as WebReact from '@deepseek-ai/dsh-client-web-react'
|
||||
import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PlatformModule } from './platform.ts'
|
||||
|
||||
/**
|
||||
* Build the seed table handed to the loader machinery at boot.
|
||||
* @returns module specifier → export-surface entity.
|
||||
* Build the static table handed to the module loader at boot.
|
||||
* @returns module specifier → export-surface entity (one entry per platform word).
|
||||
*/
|
||||
export function seedModules(): Record<string, unknown> {
|
||||
export function getStaticModules(): Record<string, unknown> {
|
||||
// The satisfies pin is the projection contract: a word added to
|
||||
// PLATFORM_MODULES without a static import here (or vice versa) fails to
|
||||
// compile instead of drifting into a runtime require miss.
|
||||
return {
|
||||
'react': React,
|
||||
'react/jsx-runtime': ReactJsxRuntime,
|
||||
@@ -29,5 +33,5 @@ export function seedModules(): Record<string, unknown> {
|
||||
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
|
||||
'@deepseek-ai/dsh-client-web-react': WebReact,
|
||||
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
|
||||
}
|
||||
} satisfies Record<PlatformModule, unknown>
|
||||
}
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AppRoot boot-gate smoke: loading page until the settled signal flips (status
|
||||
* alone never opens the gate), fail-loud plugin list, one-pass switch to the
|
||||
* real UI. The full browser chain (real loader + bundles) is the e2e's job;
|
||||
* this pins the shell-owned gate semantics.
|
||||
* alone never opens the gate), fail-loud entry list + boot failure report,
|
||||
* one-pass switch to the real UI. The full browser chain (real module system
|
||||
* + vendored Loader + bundles) is the e2e's job; this pins the shell-owned
|
||||
* gate semantics. Stores are the kernel-own signals production boot uses
|
||||
* (shell self-sufficiency: the loading page depends on no plugin package).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
// The snapshot-store engine lives with runtime now; the status-store stub
|
||||
// uses the same channel production code does.
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LoaderStatus } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AppRoot } from '@deepseek-ai/dsh-client-web/src/AppRoot.tsx'
|
||||
|
||||
function signal(): ObservableSnapshot<boolean> & { flip: () => void } {
|
||||
let value = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn) } },
|
||||
flip: () => { value = true; for (const fn of [...listeners]) fn() },
|
||||
}
|
||||
}
|
||||
import { createLoaderStatusStore, createSignal } from '@deepseek-ai/dsh-client-web/src/loader-status.ts'
|
||||
|
||||
function mount() {
|
||||
const settled = signal()
|
||||
const status = createSnapshotStore<LoaderStatus>({})
|
||||
const settled = createSignal(false)
|
||||
const error = createSignal<string | undefined>(undefined)
|
||||
const status = createLoaderStatusStore()
|
||||
let renders = 0
|
||||
const utils = render(
|
||||
<AppRoot
|
||||
settled={settled}
|
||||
status={status}
|
||||
error={error}
|
||||
renderApp={() => { renders += 1; return <div data-testid="real-ui" /> }}
|
||||
/>,
|
||||
)
|
||||
return { settled, status, counts: () => renders, ...utils }
|
||||
return { settled, status, error, counts: () => renders, ...utils }
|
||||
}
|
||||
|
||||
describe('AppRoot', () => {
|
||||
@@ -50,24 +41,34 @@ describe('AppRoot', () => {
|
||||
it('all-active status alone does not open the gate (settled signal is the only key)', () => {
|
||||
const { status, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['a'] = 'active'; d['b'] = 'active' })
|
||||
status.set('a', 'active')
|
||||
status.set('b', 'active')
|
||||
})
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('lists failed plugins and stays on the loading page', () => {
|
||||
it('lists failed entries and stays on the loading page', () => {
|
||||
const { status, getByText, queryByTestId } = mount()
|
||||
act(() => {
|
||||
status.update((d) => { d['@deepseek-ai/dsh-client-ui-theme'] = 'failed'; d['ok'] = 'active' })
|
||||
status.set('@deepseek-ai/dsh-client-ui-layout', 'failed')
|
||||
status.set('ok', 'active')
|
||||
})
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-theme')).toBeTruthy()
|
||||
expect(getByText('@deepseek-ai/dsh-client-ui-layout')).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the boot failure report even when no entry projected failed', () => {
|
||||
const { error, getByText, queryByTestId } = mount()
|
||||
act(() => { error.set('web boot: 1 entry did not activate\nx: pending (waiting for service: y)') })
|
||||
expect(getByText('Failed to load plugins')).toBeTruthy()
|
||||
expect(getByText(/waiting for service/)).toBeTruthy()
|
||||
expect(queryByTestId('real-ui')).toBeNull()
|
||||
})
|
||||
|
||||
it('flipping settled switches to the real UI in one pass', () => {
|
||||
const { settled, getByTestId, queryByText, counts } = mount()
|
||||
act(() => { settled.flip() })
|
||||
act(() => { settled.set(true) })
|
||||
expect(getByTestId('real-ui')).toBeTruthy()
|
||||
expect(queryByText('HARNESS')).toBeNull()
|
||||
expect(counts()).toBe(1)
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* bootWebShell over the REAL client loader in jsdom (runScripts:dangerously —
|
||||
* the loader's <script> execute path runs for real): fetch is stubbed to
|
||||
* serve fake bundle text, everything else is production code — seeded module
|
||||
* table, DSHClientProxy handoff, inject topology, renderer install after
|
||||
* settled, the one-line renderSlot('root') shell, and the fail-loud paths —
|
||||
* through the loader's fetch/execute seams (jsdom's <script> vm context
|
||||
* cannot reach the test window, so execute is indirect eval). The fake
|
||||
* runtime is the REAL SlotsService mounted by the real runtime plugin shape;
|
||||
* full-fidelity plugin content belongs to the apps/web e2e.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act } from '@testing-library/react'
|
||||
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
|
||||
import { createSnapshotStore, defineStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
interface BootWindow extends Window {
|
||||
__DSH_BOOT__?: { plugins: { id: string; url: string; inject: string[]; immediately?: boolean }[] }
|
||||
DSHClientProxy?: unknown
|
||||
__TEST_SLOTS_SERVICE__?: unknown
|
||||
__TEST_RUNTIME_STORE__?: { createSnapshotStore: unknown; defineStore: unknown }
|
||||
}
|
||||
const win = window as unknown as BootWindow
|
||||
|
||||
/**
|
||||
* Fake runtime half: mounts the REAL SlotsService (built-in 'root', ledger,
|
||||
* install/renderSlot) plus a minimal sessions face for the renderer host.
|
||||
* The runtime package is not a seeded library (in production it arrives as a
|
||||
* bundle), so the spec hands the real class in through a window global — the
|
||||
* plugin body and everything downstream stay production code.
|
||||
*/
|
||||
const RUNTIME_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-runtime',
|
||||
factory: (require) => {
|
||||
const SlotsService = window.__TEST_SLOTS_SERVICE__
|
||||
const { createSnapshotStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
apply: (ctx) => {
|
||||
ctx.plugin(SlotsService)
|
||||
const list = createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } }, current: 's1' })
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
cell: (id) => (id === 's1' ? { sessionId: 's1', session: { getSnapshot: () => ({}), subscribe: () => () => {} } } : undefined),
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
/** Fake layout half: ONE terminal register() call — occupy 'root', declare a
|
||||
* child, seat a store factory, expose the store round trip as a probe. */
|
||||
const LAYOUT_STUB = `
|
||||
window.DSHClientProxy.loadPlugin({
|
||||
id: 'fake-layout',
|
||||
factory: (require) => {
|
||||
const React = require('react')
|
||||
const { defineStore } = window.__TEST_RUNTIME_STORE__
|
||||
return {
|
||||
inject: ['slots'],
|
||||
apply: (ctx) => {
|
||||
const createProbeStore = () => defineStore({
|
||||
init: () => ({ sidebar: 300, details: 360 }),
|
||||
actions: {
|
||||
setSidebar: (d, px) => { d.sidebar = px },
|
||||
setDetails: (d, px) => { d.details = px },
|
||||
},
|
||||
})
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'probe.child': { kind: 'single', scope: 'root' } },
|
||||
store: createProbeStore,
|
||||
}, (props) => {
|
||||
const sw = props.useStore((st) => st.sidebar)
|
||||
const dw = props.useStore((st) => st.details)
|
||||
return React.createElement('div', {
|
||||
'data-testid': 'fake-frame',
|
||||
'data-widths': sw + 'x' + dw,
|
||||
onClick: () => { props.actions.setSidebar(311); props.actions.setDetails(411) },
|
||||
}, props.renderSlot('probe.child', {}))
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})`
|
||||
|
||||
// The shell assembly requires the layout surface under its production id.
|
||||
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
/** Loader seams: serve fake bundle text and execute it via indirect eval (jsdom's <script> vm context cannot see the test window). */
|
||||
function seams(bundles: Record<string, string>) {
|
||||
return {
|
||||
fetchBundle: (url: string): Promise<string> => {
|
||||
const hit = Object.keys(bundles).find((b) => url.endsWith(b))
|
||||
if (hit === undefined) return Promise.reject(new Error(`bundle fetch ${url} answered 404`))
|
||||
return Promise.resolve(bundles[hit]!)
|
||||
},
|
||||
executeBundle: (code: string): void => {
|
||||
(0, eval)(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mountPoint(): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
async function flushLoader(): Promise<void> {
|
||||
// fetch + per-plugin apply chain across macrotask turns; a few settle it.
|
||||
for (let i = 0; i < 10; i++) await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
|
||||
}
|
||||
|
||||
function bootPlugins(): { id: string; url: string; inject: string[]; immediately?: boolean }[] {
|
||||
return [
|
||||
{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true },
|
||||
{ id: LAYOUT_ID, url: '/plugins/fake-layout.js', inject: ['fake-runtime'] },
|
||||
]
|
||||
}
|
||||
|
||||
function fakeBundles(): Record<string, string> {
|
||||
return {
|
||||
'/plugins/fake-runtime.js': RUNTIME_STUB,
|
||||
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.DSHClientProxy
|
||||
delete win.__TEST_SLOTS_SERVICE__
|
||||
delete win.__TEST_RUNTIME_STORE__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
/** Hand the real runtime surface to the stub bundle (runtime is not a seeded library). */
|
||||
function seedSlotsService(): void {
|
||||
win.__TEST_SLOTS_SERVICE__ = SlotsService
|
||||
win.__TEST_RUNTIME_STORE__ = { createSnapshotStore, defineStore }
|
||||
}
|
||||
|
||||
describe('bootWebShell (real loader + real script execution)', () => {
|
||||
it('loading page → settled → renderer installed → assembled UI in one pass; unmount clears the tree', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
document.title = 'DeepSeek Harness'
|
||||
let unmount: (() => void) | undefined
|
||||
act(() => { unmount = bootWebShell(el, seams(fakeBundles())) })
|
||||
expect(el.textContent).toContain('HARNESS')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
|
||||
await flushLoader()
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).not.toBeNull()
|
||||
expect(el.textContent).not.toContain('HARNESS')
|
||||
expect(document.title).toBe('S1 — DeepSeek Harness')
|
||||
|
||||
act(() => { unmount!() })
|
||||
expect(el.childElementCount).toBe(0)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('store seat round-trips through the entry props (useStore + actions)', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: bootPlugins() }
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams(fakeBundles())) })
|
||||
await flushLoader()
|
||||
const frame = el.querySelector('[data-testid="fake-frame"]')
|
||||
expect(frame).not.toBeNull()
|
||||
// Width write/read round trip through the framework-delivered store share.
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
|
||||
act(() => { (frame as HTMLElement).click() })
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('311x411')
|
||||
})
|
||||
|
||||
it('fail loud: a 404 bundle keeps the loading page and lists the plugin id', async () => {
|
||||
win.__DSH_BOOT__ = { plugins: [{ id: 'absent-plugin', url: '/plugins/absent.js', inject: [] }] }
|
||||
const el = mountPoint()
|
||||
act(() => { bootWebShell(el, seams({})) })
|
||||
await flushLoader()
|
||||
expect(el.textContent).toContain('Failed to load plugins')
|
||||
expect(el.textContent).toContain('absent-plugin')
|
||||
expect(el.querySelector('[data-testid="fake-frame"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("fail loud: rendering with no 'root' registration throws through the shell error surface", async () => {
|
||||
// Runtime loads (slots service present, renderer installed) but no layout
|
||||
// entry ever registers into 'root' — the ctx-level renderSlot must throw.
|
||||
win.__DSH_BOOT__ = {
|
||||
plugins: [{ id: 'fake-runtime', url: '/plugins/fake-runtime.js', inject: [], immediately: true }],
|
||||
}
|
||||
seedSlotsService()
|
||||
const el = mountPoint()
|
||||
// React logs the render error before the boundary rethrow reaches us — keep the spec output clean.
|
||||
const consoleError = console.error
|
||||
console.error = () => {}
|
||||
try {
|
||||
act(() => { bootWebShell(el, seams({ '/plugins/fake-runtime.js': RUNTIME_STUB })) })
|
||||
let thrown: unknown
|
||||
try {
|
||||
await flushLoader()
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(String(thrown)).toMatch(/'root' has no registration/)
|
||||
} finally {
|
||||
console.error = consoleError
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRenderApp — assembly contract', () => {
|
||||
it('is exactly the ctx-level root render call (fail-loud before install)', async () => {
|
||||
const { buildRenderApp } = await import('@deepseek-ai/dsh-client-web')
|
||||
const { Context } = await import('cordis')
|
||||
const { SlotsService } = await import('@deepseek-ai/dsh-client-runtime/client')
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber.await()
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore({ ids: [], byId: {}, current: undefined }),
|
||||
})
|
||||
const renderApp = buildRenderApp({ ctx, requireModule: () => undefined })
|
||||
expect(renderApp).toBeTypeOf('function')
|
||||
// No renderer installed: the one-line shell must surface the boot-order error.
|
||||
expect(() => renderApp()).toThrow(/renderer not installed/)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../modules"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
@@ -20,18 +26,9 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -102,9 +102,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
|
||||
"@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
|
||||
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
|
||||
"@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],
|
||||
"@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"],
|
||||
"@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"],
|
||||
"@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"],
|
||||
"@deepseek-ai/dsh-client-runtime/loader": ["./packages/client/runtime/src/client/loader"],
|
||||
"@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"],
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"],
|
||||
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
{ "path": "./packages/client/ui-slots" },
|
||||
{ "path": "./packages/client/ui-primitives" },
|
||||
{ "path": "./packages/client/web-react" },
|
||||
{ "path": "./packages/client/modules" },
|
||||
{ "path": "./packages/client/hmr" },
|
||||
{ "path": "./packages/client/connection" },
|
||||
{ "path": "./packages/client/runtime" },
|
||||
{ "path": "./packages/client/ui-layout" },
|
||||
|
||||
Reference in New Issue
Block a user