From fb47f61a8326aab9fd03a7395466f4f533b6c870 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:56:18 +0800 Subject: [PATCH] refactor(gui): host graph from dshClient discovery; webserver self-watches bundles for HMR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry scans mounted Loader entries' dshClient declarations and composes __DSH_BOOT__ {rev, entries} — inject edges and the immediately mark come from manifests, never hand-copied; malformed fields fail loud at load. The composing app owns one flat roster plus the --dev switch (hmr row and bundle watching are dev-graph decisions). The rebuild signal is the webserver's own observation: in dev mode the registry stat-polls each scanned bundle (fs.watchFile; polling because network mounts deliver no inotify), re-hashes on change, and broadcasts a rebuilt frame on the /plugins/events SSE channel only when the rev actually changed. Watch membership follows the table across rescans; dispose drops all watches; a torn read self-heals on the next tick. The POST /plugins/rebuilt endpoint is gone — builders and the host share zero protocol. dsh web --dev logs the watched bundle list and each rebuilt id with its rev transition. --- apps/cli/package.json | 9 + apps/cli/src/web.ts | 56 ++++- apps/cli/tsconfig.json | 11 +- packages/host/runtime/package.json | 11 - packages/host/runtime/src/index.ts | 2 +- packages/host/runtime/src/web-plugins.ts | 55 +++-- .../host/runtime/tests/web-plugins.e2e.ts | 82 ------- .../host/runtime/tests/web-plugins.spec.ts | 67 +++--- packages/host/runtime/tsconfig.json | 30 --- packages/host/webserver/src/index.ts | 53 +++-- packages/host/webserver/src/invariant.ts | 26 +-- packages/host/webserver/src/plugin-events.ts | 56 +++++ packages/host/webserver/src/web-plugins.ts | 203 ++++++++++++++---- .../host/webserver/tests/invariant.spec.ts | 18 +- .../host/webserver/tests/web-plugins.spec.ts | 111 +++++++--- .../host/webserver/tests/webserver.spec.ts | 85 ++++++-- 16 files changed, 563 insertions(+), 312 deletions(-) delete mode 100644 packages/host/runtime/tests/web-plugins.e2e.ts create mode 100644 packages/host/webserver/src/plugin-events.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index fd744fa02c..1062ede609 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,6 +14,15 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-runtime": "workspace:^", diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 917e4e137b..a18720d986 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -13,12 +13,40 @@ import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-ho const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' +// --- Client composition (composition decisions live in the composing app) --- +// The composition layer owns one decision: which plugin packages mount (the +// roster). Dependency edges and the boot prefetch tier live in each package's +// dshClient declaration. + +/** + * Dev-only plugin: the client HMR driver. Whether it composes in is a + * deployment decision — the dev graph includes its row, the prod graph does + * not mount it at all. + */ +const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr' + +/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */ +const CLIENT_BUNDLE_POLL_MS = 500 + +/** The client plugin roster (flat; per-row boot behavior comes from manifests). */ +const CLIENT_PACKAGES = [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-theme', + '@deepseek-ai/dsh-client-i18n', + '@deepseek-ai/dsh-client-ui-layout', + '@deepseek-ai/dsh-client-ui-sidebar', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-trajectory', +] as const + export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ args: argv, options: { host: { type: 'string', default: LOOPBACK_HOST }, port: { type: 'string', default: '3080' }, + dev: { type: 'boolean', default: false }, }, allowPositionals: false, }) @@ -44,15 +72,37 @@ export async function runWeb(argv: string[]): Promise { }, }) - // Web UI plugin chain: in-memory Loader tree over the eight UI packages, - // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. - const mounted = await mountWebPlugins(host.ctx) + // Client plugin chain: in-memory Loader tree over the composed roster, then + // the registry that feeds the __DSH_BOOT__ entry graph and + // /plugins//client.js. All row content comes from dshClient discovery + // over the mounted roster (dev adds the HMR driver row and turns on the + // bundle watch that drives rebuilt frames). + const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []] + const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url) const webPlugins = createHostWebPluginRegistry({ ctx: host.ctx, loader: mounted.loader, resolvePkgJson: mounted.resolvePkgJson, onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) }, + ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {}, }) + if (values.dev) { + // Dev visibility (the registry is a library and never prints): list what + // the bundle watch covers, then log every observed rebuild. This is a + // second onRebuilt subscription — the SSE relay inside the webserver is + // unaffected (multicast). + const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev])) + const bundlePaths = [...revs.keys()] + .map(id => webPlugins.clientPath(id)) + .filter((path): path is string => path !== undefined) + console.log( + `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`, + ) + webPlugins.onRebuilt((id, rev) => { + console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`) + revs.set(id, rev) + }) + } // Published so the webserver invariant companion can audit manifest/bundle // consistency; nothing else reads this key. host.ctx.reflect.provide('webPlugins', webPlugins) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ee9382171a..8c6385cf22 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../packages/host/webserver" }, { "path": "../../packages/core/session" }, { "path": "../../packages/ui/app-boot" }, - { "path": "../../packages/util/paths" } + { "path": "../../packages/util/paths" }, + { "path": "../../packages/client/connection" }, + { "path": "../../packages/client/hmr" }, + { "path": "../../packages/client/runtime" }, + { "path": "../../packages/client/ui-theme" }, + { "path": "../../packages/client/i18n" }, + { "path": "../../packages/client/ui-layout" }, + { "path": "../../packages/client/ui-sidebar" }, + { "path": "../../packages/client/ui-conversation" }, + { "path": "../../packages/client/ui-trajectory" } ] } diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index a8c1b9fbfb..407cbad0f1 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -32,15 +32,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-i18n": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-question": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -72,8 +63,6 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^" }, "peerDependencies": { diff --git a/packages/host/runtime/src/index.ts b/packages/host/runtime/src/index.ts index 55e143a495..af10f0be16 100644 --- a/packages/host/runtime/src/index.ts +++ b/packages/host/runtime/src/index.ts @@ -11,4 +11,4 @@ export { createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' export { startHost } from './start.ts' export type { StartHostOptions, RunningHost } from './start.ts' -export { mountWebPlugins, WEB_UI_PLUGINS } from './web-plugins.ts' +export { mountWebPlugins } from './web-plugins.ts' diff --git a/packages/host/runtime/src/web-plugins.ts b/packages/host/runtime/src/web-plugins.ts index 0967be8c3e..5866d46492 100644 --- a/packages/host/runtime/src/web-plugins.ts +++ b/packages/host/runtime/src/web-plugins.ts @@ -1,28 +1,16 @@ /** - * Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory - * entry tree listing the nine UI plugin packages (the P-I config-source bar — - * a cordis.yml file form comes later; install/remove currently means editing - * this list and restarting). The web plugin registry discovers the entries by - * their package.json dshClient declarations; feature packages may also mount - * their interface-specific host half through the same lifecycle. + * Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory + * entry tree over the caller-supplied client plugin roster. The roster is a + * composition decision and lives in the composing app (apps/cli); this module + * only owns the mount/settle/fail-loud mechanics. The web plugin registry + * discovers fetch-arrival entries among the mounted packages by their + * package.json dshClient declarations; node halves are empty applies, so + * mounting them here costs nothing beyond Loader governance. */ import { createRequire } from 'node:module' import type { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -/** The nine UI plugin packages served to the browser (order = manifest order). */ -export const WEB_UI_PLUGINS = [ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-theme', - '@deepseek-ai/dsh-client-i18n', - '@deepseek-ai/dsh-client-ui-layout', - '@deepseek-ai/dsh-client-ui-sidebar', - '@deepseek-ai/dsh-client-ui-conversation', - '@deepseek-ai/dsh-client-ui-question', - '@deepseek-ai/dsh-client-ui-trajectory', -] as const - /** What the shell hands the web plugin registry (loader view + module resolution seam). */ export interface MountedWebPlugins { /** Entry enumeration surface of the mounted Loader (registry scan source). */ @@ -32,31 +20,36 @@ export interface MountedWebPlugins { } /** - * Mount the Loader (when absent) and create one in-memory entry per UI - * plugin, then wait for the tree to settle. A plugin whose import fails - * leaves its entry fiber-less — surfaced here as a loud throw listing the - * failures (misconfiguration must not silently drop a UI plugin). + * Mount the Loader (when absent) and create one in-memory entry per client + * plugin package, then wait for the tree to settle. A plugin whose import + * fails leaves its entry fiber-less — surfaced here as a loud throw listing + * the failures (misconfiguration must not silently drop a client plugin). * @param ctx - host root context (bootHost product). + * @param plugins - client plugin package names to mount (the composition layer's roster). + * @param anchor - module URL anchoring bare-specifier resolution (the composing + * app's import.meta.url; the roster packages must be dependencies of that app). * @returns the loader view and package.json resolver the registry consumes. */ -export async function mountWebPlugins(ctx: Context): Promise { +export async function mountWebPlugins( + ctx: Context, plugins: readonly string[], anchor: string, +): Promise { // The Loader resolves bare specifiers against ctx.baseUrl; without one the - // import silently fails and every entry stays fiber-less. This package - // depends on all nine UI plugins, so its own URL is the right anchor. - ctx.baseUrl ??= import.meta.url + // import silently fails and every entry stays fiber-less. The composing app + // declares the roster packages as dependencies, so its URL is the right anchor. + ctx.baseUrl ??= anchor if (ctx.get('loader') === undefined) await ctx.plugin(Loader) const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name)) - for (const name of WEB_UI_PLUGINS) { + for (const name of plugins) { if (!existing.has(name)) await ctx.loader.create({ name }) } await ctx.loader.await() const dead = [...ctx.loader.entries()] - .filter(entry => (WEB_UI_PLUGINS as readonly string[]).includes(entry.options.name)) + .filter(entry => plugins.includes(entry.options.name)) .filter(entry => entry.fiber === undefined && !entry.disabled) if (dead.length > 0) { - throw new Error(`web-plugins: UI plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`) + throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`) } - const require = createRequire(import.meta.url) + const require = createRequire(anchor) return { loader: ctx.loader, resolvePkgJson: name => require.resolve(`${name}/package.json`), diff --git a/packages/host/runtime/tests/web-plugins.e2e.ts b/packages/host/runtime/tests/web-plugins.e2e.ts deleted file mode 100644 index 8bf551b57e..0000000000 --- a/packages/host/runtime/tests/web-plugins.e2e.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Web UI plugin assembly: the in-memory Loader tree mounts all nine UI - * packages (node halves), and the webserver registry built over it yields the - * full __DSH_BOOT__ manifest — the P-I config-source bar end to end. - * - * The Loader imports plugin packages through their exports maps (lib/), so - * this is a built-artifact e2e: it skips until the workspace build has run - * (`pnpm run build`), like the other built-* e2e suites. - */ -import { existsSync } from 'node:fs' -import { createRequire } from 'node:module' -import { Context } from 'cordis' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { afterEach, describe, expect, it } from 'vitest' -import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver' -import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts' - -const nodeRequire = createRequire(import.meta.url) -const built = WEB_UI_PLUGINS.every((name) => { - try { - return existsSync(nodeRequire.resolve(name)) - } catch { - return false - } -}) - -let root: Context | undefined - -afterEach(async () => { - await root?.fiber.dispose() - root = undefined -}) - -describe.skipIf(!built)('mountWebPlugins + registry', () => { - async function rootWithHostServices(): Promise { - root = new Context() - await root.plugin(SystemPrompt) - await root.plugin(ToolRegistry) - await root.plugin(UserInteractionService) - return root - } - - it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => { - root = await rootWithHostServices() - const mounted = await mountWebPlugins(root) - const registry = createHostWebPluginRegistry({ - ctx: root, - loader: mounted.loader, - resolvePkgJson: mounted.resolvePkgJson, - onError: (err) => { throw err }, - }) - const rows = registry.snapshot() - expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS]) - // The infra four are the early-load group; the UI four are not. - const immediate = rows.filter(r => r.immediately === true).map(r => r.id) - expect(immediate).toEqual([ - '@deepseek-ai/dsh-client-connection', - '@deepseek-ai/dsh-client-runtime', - '@deepseek-ai/dsh-client-ui-theme', - '@deepseek-ai/dsh-client-i18n', - ]) - // Every row resolves a client path under its own package lib/. - for (const row of rows) { - expect(registry.clientPath(row.id)).toMatch(/lib[/\\]client\.js$/) - expect(row.url).toBe(`/plugins/${row.id}/client.js`) - } - registry.dispose() - }) - - it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => { - root = await rootWithHostServices() - await mountWebPlugins(root) - const second = await mountWebPlugins(root) - // ctx.loader hands out a fresh traced proxy per access, so loader identity - // is not assertable; the observable contract is a single entry per package. - const names = [...second.loader.entries()].map(e => e.options.name) - .filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n)) - expect(names.length).toBe(WEB_UI_PLUGINS.length) - }) -}) diff --git a/packages/host/runtime/tests/web-plugins.spec.ts b/packages/host/runtime/tests/web-plugins.spec.ts index 4f9c4fcfe9..b558c253c2 100644 --- a/packages/host/runtime/tests/web-plugins.spec.ts +++ b/packages/host/runtime/tests/web-plugins.spec.ts @@ -1,13 +1,20 @@ /** - * mountWebPlugins unit coverage (keyless; the real nine-package walk is the - * built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry - * creation with idempotent reuse, the fiber-less fail-loud sweep, and the - * resolver seam — is exercised against a stubbed loader service so it runs - * without built lib/ artifacts. + * mountWebPlugins unit coverage (keyless). The Loader-facing behavior — + * baseUrl anchoring, entry creation with idempotent reuse, the fiber-less + * fail-loud sweep, and the resolver seam — is exercised against a stubbed + * loader service so it runs without built lib/ artifacts. The roster is + * caller-supplied now (composition moved to apps/cli), so these tests pass + * their own lists. */ import { Context } from 'cordis' import { afterEach, describe, expect, it } from 'vitest' -import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts' +import { mountWebPlugins } from '../src/web-plugins.ts' + +const ROSTER = [ + '@deepseek-ai/dsh-plugin-a', + '@deepseek-ai/dsh-plugin-b', + '@deepseek-ai/dsh-plugin-c', +] as const interface FakeEntry { options: { name: string } @@ -47,60 +54,50 @@ function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void) } describe('mountWebPlugins (stubbed loader)', () => { - it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => { + it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => { const entriesList: FakeEntry[] = [] const { ctx, loader } = withLoader(entriesList, (name) => { entriesList.push({ options: { name }, fiber: {}, disabled: false }) }) - const mounted = await mountWebPlugins(ctx) - expect(loader.created).toEqual([...WEB_UI_PLUGINS]) + const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url) + expect(loader.created).toEqual([...ROSTER]) expect(loader.awaited).toBe(1) - expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS]) - // The resolver resolves this package's own manifest through real module resolution. + expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER]) + // The resolver resolves a real package manifest through real module resolution, anchored at this test file. expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/) expect(ctx.baseUrl).toBeDefined() }) it('reuses existing entries (idempotent mount creates no duplicates)', async () => { - const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false })) + const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false })) const { ctx, loader } = withLoader(preexisting) - await mountWebPlugins(ctx) + await mountWebPlugins(ctx, ROSTER, import.meta.url) expect(loader.created).toEqual([]) }) - it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => { + it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => { const entriesList: FakeEntry[] = [] const { ctx } = withLoader(entriesList, (name) => { - // First two load; the rest stay fiber-less (import failed silently). - entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false }) + // First one loads; the rest stay fiber-less (import failed silently). + entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false }) }) - await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/) + await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)) + .rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/) }) it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => { - const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true })) + const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true })) const { ctx } = withLoader(entriesList) - await expect(mountWebPlugins(ctx)).resolves.toBeDefined() + await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined() }) it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => { root = new Context() - // Environment-dependent outcome: with built lib/ the nine imports load - // and the mount resolves; without them every entry stays fiber-less and - // the sweep throws its loud list. Either way the branch under test is the - // Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of - // expect()'s formatting path (pretty-format probes throw on them). - // Plain string: the success sentinel and error text share one channel. - let outcome: string - try { - await mountWebPlugins(root) - outcome = 'resolved' - } catch (error) { - outcome = error instanceof Error ? error.message : String(error) - } - expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true) + // An empty roster keeps this keyless and artifact-free: the branch under + // test is only the Loader auto-mount. + await mountWebPlugins(root, [], import.meta.url) expect(root.get('loader') !== undefined).toBe(true) - }, 30_000) // built-env run imports nine real plugin packages through the Loader + }, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default it('keeps a caller-set baseUrl (anchors only when absent)', async () => { const entriesList: FakeEntry[] = [] @@ -108,7 +105,7 @@ describe('mountWebPlugins (stubbed loader)', () => { entriesList.push({ options: { name }, fiber: {}, disabled: false }) }) ctx.baseUrl = 'file:///caller/anchor/' - await mountWebPlugins(ctx) + await mountWebPlugins(ctx, ROSTER, import.meta.url) expect(ctx.baseUrl).toBe('file:///caller/anchor/') }) }) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 202217f0f3..cd2eee67cc 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -68,9 +68,6 @@ { "path": "../../fs/tool-fs-search" }, - { - "path": "../../context/workspace-context" - }, { "path": "../../llm/token-meter" }, @@ -124,33 +121,6 @@ }, { "path": "../../../vendor/loader" - }, - { - "path": "../../client/connection" - }, - { - "path": "../../client/runtime" - }, - { - "path": "../../client/ui-theme" - }, - { - "path": "../../client/i18n" - }, - { - "path": "../../client/ui-layout" - }, - { - "path": "../../client/ui-sidebar" - }, - { - "path": "../../client/ui-conversation" - }, - { - "path": "../../client/ui-question" - }, - { - "path": "../../client/ui-trajectory" } ] } diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 074bfe395c..60ad92d18c 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -13,12 +13,14 @@ import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import { dirname } from 'node:path' import { serveStatic } from './static.ts' -import type { HostWebPluginRegistry } from './web-plugins.ts' +import { createPluginEventChannel } from './plugin-events.ts' +import type { HostWebPluginRegistry, WebBootGraph } from './web-plugins.ts' export { createHostWebPluginRegistry } from './web-plugins.ts' export type { - HostWebPluginRegistry, LoaderEntryView, LoaderView, WebPluginBootEntry, WebPluginRegistryDeps, + HostWebPluginRegistry, LoaderEntryView, LoaderView, WebBootEntry, WebBootGraph, WebPluginRegistryDeps, } from './web-plugins.ts' +export type { PluginEventChannel, PluginEventFrame } from './plugin-events.ts' /** Options for startWebServer. */ export interface WebServerOptions { @@ -34,11 +36,14 @@ export interface WebServerOptions { /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */ apiHandler: { fetch: typeof fetch } /** - * Web plugin table. When present, every index.html response carries a - * `window.__DSH_BOOT__` manifest script and `/plugins//client.js` serves - * each plugin's client bundle. Absent = both surfaces off (carrier-only use). + * Web plugin table. When present, every index.html response carries the + * `window.__DSH_BOOT__` entry graph script, `/plugins//client.js` serves + * each fetch entry's client bundle, and `GET /plugins/events` streams graph/ + * rebuilt frames (SSE) — rebuilt frames ride the registry's own bundle-watch + * notifications (`onRebuilt`). Absent = all three surfaces off (carrier-only + * use). */ - webPlugins?: Pick + webPlugins?: Pick } /** Listening web server handle. */ @@ -70,8 +75,14 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) const distRoot = dirname(distIndex) const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { const html = await readFile(distIndex, 'utf8') - return injectBootManifest(html, webPlugins.snapshot()) + return injectBootManifest(html, webPlugins.graph()) } + const pluginEvents = webPlugins === undefined ? undefined : createPluginEventChannel() + // Rebuilt frames come from the registry's own bundle watch (dev mode); a + // prod registry without watching simply never notifies. + const unsubscribeRebuilt = webPlugins !== undefined && pluginEvents !== undefined + ? webPlugins.onRebuilt((id, rev) => { pluginEvents.broadcast({ type: 'rebuilt', id, rev }) }) + : undefined const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server @@ -86,6 +97,10 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) res.end() return } + if (webPlugins !== undefined && pluginEvents !== undefined && rawPath === '/plugins/events') { + pluginEvents.connect(res, webPlugins.graph()) + return + } if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) { await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins) return @@ -110,6 +125,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) let closing: Promise | undefined const close = (): Promise => (closing ??= new Promise((resolveClose) => { + unsubscribeRebuilt?.() server.close(() => { resolveClose() }) server.closeAllConnections() })) @@ -125,15 +141,15 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) } /** - * Inject the boot manifest into index.html: `window.__DSH_BOOT__` as the first - * script in (before the shell bundle reads it). `<` is escaped in the - * JSON so plugin-controlled strings cannot break out of the script element. + * Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the + * first script in (before the shell bundle reads it). `<` is escaped in + * the JSON so plugin-controlled strings cannot break out of the script element. * @param html - the index.html source. - * @param plugins - the manifest rows from the registry snapshot. - * @returns the html with the manifest script injected. + * @param graph - the composed entry graph from the registry. + * @returns the html with the graph script injected. */ -export function injectBootManifest(html: string, plugins: readonly unknown[]): string { - const json = JSON.stringify({ plugins }).replaceAll('<', '\\u003c') +export function injectBootManifest(html: string, graph: WebBootGraph): string { + const json = JSON.stringify(graph).replaceAll('<', '\\u003c') const script = `` const head = html.indexOf('') if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` @@ -141,7 +157,12 @@ export function injectBootManifest(html: string, plugins: readonly unknown[]): s return `${script}${html}` } -/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */ +/** + * Serve one plugin client bundle from the registry table (unknown id = 404; + * the id may contain a scope slash). The `?rev=` query is a cache-busting + * parameter only — serving ignores it; `no-cache` makes the browser revalidate + * so a stale rev never sticks. + */ async function servePluginBundle( pathname: string, res: ServerResponse, webPlugins: Pick, ): Promise { @@ -154,7 +175,7 @@ async function servePluginBundle( } try { const body = await readFile(path) - res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }) + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }) res.end(body) } catch { // Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page. diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index 87e3463121..a204c93775 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -15,25 +15,27 @@ export const name = 'host-webserver-invariant' export const inject = ['invariants'] /** - * Owned relation: the web plugin registry's boot manifest must stay - * self-consistent — every snapshot() row must resolve a clientPath under the - * same id (the /plugins//client.js URL it advertises would otherwise 404 - * on a browser that just received the manifest). Checked synchronously on - * every rescan trigger (cordis 'internal/plugin'): snapshot() and - * clientPath() read the same table object, so the relation is - * self-consistent at any instant — no need to wait out the registry's own - * debounced rescan. The registry arrives through the context key the - * assembly publishes it under. + * Owned relation: the web plugin registry's boot entry graph must stay + * self-consistent — every row must resolve a clientPath under the same id + * (the /plugins//client.js URL it advertises would otherwise 404 on a + * browser that just received the graph). Checked synchronously on every + * rescan trigger (cordis 'internal/plugin'): graph() and clientPath() read + * the same table object, so the relation is self-consistent at any instant — + * no need to wait out the registry's own debounced rescan. The registry + * arrives through the context key the assembly publishes it under. */ const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/plugin', () => { const registry = ctx.get('webPlugins') as - | { snapshot(): { id: string; url: string }[]; clientPath(id: string): string | undefined } + | { + graph(): { entries: { id: string; url: string }[] } + clientPath(id: string): string | undefined + } | undefined if (registry === undefined) return // carrier-only deployments never publish the registry - for (const row of registry.snapshot()) { + for (const row of registry.graph().entries) { if (registry.clientPath(row.id) === undefined) { - fail(`web plugin manifest row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) + fail(`web plugin graph row "${row.id}" advertises ${row.url} but resolves no client bundle path — the served __DSH_BOOT__ would 404 on fetch`) } } }, { global: true }) diff --git a/packages/host/webserver/src/plugin-events.ts b/packages/host/webserver/src/plugin-events.ts new file mode 100644 index 0000000000..b438edf948 --- /dev/null +++ b/packages/host/webserver/src/plugin-events.ts @@ -0,0 +1,56 @@ +/** + * `/plugins/events` SSE channel: the system-side push surface for the client + * entry graph (connect → current graph frame; dev rebuild → rebuilt frame). + * Presentation-only wire — frames never enter the session log (distinct from + * the /api/* session SSE, which is api-contract territory). Connections are + * plain node:http responses held in a set; the server's closeAllConnections + * tears them down on shutdown. + */ + +import type { ServerResponse } from 'node:http' +import type { WebBootGraph } from './web-plugins.ts' + +/** One `/plugins/events` frame: the full graph on connect, or one rebuilt bundle notice. */ +export type PluginEventFrame = + | { type: 'graph'; graph: WebBootGraph } + | { type: 'rebuilt'; id: string; rev: string } + +/** Broadcast surface owned by the webserver routing layer. */ +export interface PluginEventChannel { + /** Adopt one incoming SSE request: writes the SSE preamble and the current-graph frame, then keeps the response open. */ + connect(res: ServerResponse, graph: WebBootGraph): void + /** Push one frame to every open connection. */ + broadcast(frame: PluginEventFrame): void +} + +/** Serialize one frame as an SSE data line. */ +function sseData(frame: PluginEventFrame): string { + return `data: ${JSON.stringify(frame)}\n\n` +} + +/** + * Create the channel (one per running server). + * @returns the connect/broadcast surface. + */ +export function createPluginEventChannel(): PluginEventChannel { + const connections = new Set() + return { + connect(res, graph) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + }) + // Comment line on open so clients/proxies see a live channel even when + // no rebuild ever happens; EventSource frame parsing skips it naturally. + res.write(': connected\n\n') + res.write(sseData({ type: 'graph', graph })) + connections.add(res) + res.on('close', () => { connections.delete(res) }) + }, + broadcast(frame) { + const line = sseData(frame) + for (const res of connections) res.write(line) + }, + } +} diff --git a/packages/host/webserver/src/web-plugins.ts b/packages/host/webserver/src/web-plugins.ts index ee1e72f34c..7998576f4e 100644 --- a/packages/host/webserver/src/web-plugins.ts +++ b/packages/host/webserver/src/web-plugins.ts @@ -1,10 +1,17 @@ /** - * HostWebPluginRegistry: discovers web-client plugins among the host Loader's - * loaded entries by their package.json `dshClient` declaration and resolves - * each one's client bundle path from `exports["./client"]`. The webserver - * consumes the table to emit `window.__DSH_BOOT__` and to serve - * `GET /plugins//client.js`. Discovery is declaration-only: plugin authors - * write package.json; no serve() call surface exists. + * HostWebPluginRegistry: composes the client entry graph served as + * `window.__DSH_BOOT__` ({rev, entries}). Every row is discovered among the + * host Loader's loaded entries by its package.json `dshClient` declaration + * (all client plugin packages arrive by fetch — one uniform bundle shape), + * resolving each one's client bundle path from `exports["./client"]` and + * hashing the bundle content into a `rev` (cache busting + HMR diff anchor). + * `inject` edges and the `immediately` prefetch mark come from the manifest + * (dshClient — the package owns its dependency edges and its boot tier); the + * composition layer contributes only the roster. The webserver consumes the + * table to emit the boot graph and to serve `GET /plugins//client.js`; + * in dev mode the registry additionally stat-polls each scanned bundle file + * and re-hashes + notifies `onRebuilt` subscribers on change (the rebuild + * signal is the registry's own observation — no builder protocol exists). * * The vendored loader emits no "entry loaded" event (only `loader/entry-init`, * which fires at Entry construction before import/apply), so the registry @@ -14,33 +21,59 @@ * fresh within a process lifetime. */ -import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { readFileSync, unwatchFile, watchFile } from 'node:fs' +import type { Stats } from 'node:fs' import { dirname, join } from 'node:path' import type { Context } from 'cordis' -/** One `window.__DSH_BOOT__.plugins` row (wire shape of api-contracts v3 §9.2). */ -export interface WebPluginBootEntry { - /** Plugin id = package name (may contain a scope slash). */ +/** One composed client entry (`window.__DSH_BOOT__.entries` row). */ +export interface WebBootEntry { + /** Entry name == package name. */ id: string - /** Bundle URL served by this webserver (`/plugins//client.js`). */ + /** Bundle URL served by this webserver (`/plugins//client.js?rev=`). */ url: string - /** Client-half load dependencies (plugin ids), topologically ordered by the client loader. */ - inject: string[] - /** Marks the early-load group: fetched in parallel and applied before all other plugins. */ + /** Bundle content hash (sha1, shortened). */ + rev: string + /** Package-name dependency edges from the manifest (dshClient.inject), informational (preflight/HMR display). */ + inject?: string[] + /** Boot phase-one prefetch tier: the shell fetches these bundles in parallel before creating entries. */ immediately?: boolean } -/** The web plugin table consumed by the boot injection and the bundle endpoint. */ +/** The composed entry graph: injected into index.html and pushed on /plugins/events connect. */ +export interface WebBootGraph { + /** Consistency anchor over all rows: changes whenever any entry row changes. */ + rev: string + /** All composed entries (order carries no semantics; governance ordering is the client Loader's job). */ + entries: WebBootEntry[] +} + +/** The web plugin table consumed by the boot injection, the bundle endpoint, and the rebuild channel. */ export interface HostWebPluginRegistry { - /** Current manifest rows (stable order: loader entry order). */ - snapshot(): WebPluginBootEntry[] + /** Current composed entry graph (stable object between changes). */ + graph(): WebBootGraph /** - * Absolute path of a plugin's client bundle. - * @param id - plugin id (package name). + * Absolute path of an entry's client bundle. + * @param id - entry id (package name). * @returns the path, or undefined for an unknown id. */ clientPath(id: string): string | undefined - /** Remove the loader subscription. */ + /** + * Re-hash one entry's bundle: updates the row's rev/url and the graph rev. + * The dev bundle watch calls this on every observed file change. + * @param id - entry id (package name). + * @returns the new bundle rev, or undefined for an unknown id. + */ + rebuilt(id: string): string | undefined + /** + * Subscribe to bundle rebuilds observed by the dev watch (only fires when + * the re-hash produced a different rev — an unchanged bundle is silent). + * @param listener - receives the entry id and its new bundle rev. + * @returns the unsubscriber. + */ + onRebuilt(listener: (id: string, rev: string) => void): () => void + /** Remove the loader subscription, all bundle watches, and all rebuild listeners. */ dispose(): void } @@ -72,17 +105,28 @@ export interface WebPluginRegistryDeps { resolvePkgJson: (name: string) => string /** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */ onError: (err: Error) => void + /** + * Dev-mode bundle watching: stat-poll every scanned row's client bundle + * (fs.watchFile — polling by design: network mounts deliver no inotify + * events) and re-hash + notify onRebuilt subscribers on change. Absent = + * no watching (prod composition). + */ + watch?: { + /** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */ + intervalMs?: number + } } /** package.json `dshClient` declaration shape (file boundary — validated field by field). */ interface DshClientDeclaration { inject?: string[] platform: string + /** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */ immediately?: boolean } interface WebPluginRecord { - entry: WebPluginBootEntry + entry: WebBootEntry clientPath: string } @@ -122,15 +166,94 @@ function clientExportOf(name: string, exportsField: unknown): string | undefined throw new Error(`web-plugins: ${name} exports["./client"] has an unsupported shape`) } +/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ +function shortHash(input: string | Buffer): string { + return createHash('sha1').update(input).digest('hex').slice(0, 12) +} + +/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */ +function graphRow(id: string, rev: string, inject: string[] | undefined, immediately: boolean): WebBootEntry { + return { + id, + url: `/plugins/${id}/client.js?rev=${rev}`, + rev, + ...(inject !== undefined ? { inject } : {}), + ...(immediately ? { immediately: true } : {}), + } +} + +/** Compose the graph value from the current table. */ +function composeGraph(table: Map): WebBootGraph { + const entries = [...table.values()].map(record => record.entry) + return { rev: shortHash(JSON.stringify(entries)), entries } +} + /** * Build the web plugin registry: scan once synchronously (a malformed - * declaration throws here — load-time fail loud), then rescan on - * `internal/plugin`, microtask-debounced (failures go to `deps.onError`). - * @param deps - loader view, resolution hook, and error sink (see {@link WebPluginRegistryDeps}). + * declaration, an unbuilt bundle, or an invalid watch interval throws here — + * load-time fail loud), then rescan on `internal/plugin`, microtask-debounced + * (failures go to `deps.onError`). With `deps.watch`, every scanned bundle + * file is stat-polled and a content change re-hashes the row and notifies + * `onRebuilt` subscribers. + * @param deps - loader view, resolution hook, error sink, and optional dev watch (see {@link WebPluginRegistryDeps}). * @returns the registry handle. */ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWebPluginRegistry { + const watchInterval = deps.watch === undefined ? undefined : deps.watch.intervalMs ?? 500 + if (watchInterval !== undefined && (!Number.isInteger(watchInterval) || watchInterval <= 0)) { + throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`) + } + let table = scan(deps) + let graph = composeGraph(table) + const rebuildListeners = new Set<(id: string, rev: string) => void>() + + const rebuilt = (id: string): string | undefined => { + const record = table.get(id) + if (record === undefined) return undefined + const rev = shortHash(readFileSync(record.clientPath)) + record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true) + graph = composeGraph(table) + return rev + } + + // Dev bundle watch: one fs.watchFile stat poll per table row. A torn read + // of a half-written bundle self-heals — the ongoing write keeps changing + // the stats, so the next poll tick re-hashes the completed file. + const watched = new Map void }>() + const syncWatches = (): void => { + if (watchInterval === undefined) return + for (const [id, watch] of watched) { + if (table.get(id)?.clientPath === watch.path) continue + unwatchFile(watch.path, watch.listener) + watched.delete(id) + } + for (const [id, record] of table) { + if (watched.has(id)) continue + const listener = (curr: Stats, prev: Stats): void => { + // fs.watchFile fires on any stat delta (atime included); only content + // signals count. An all-zero curr means the file vanished mid-rebuild + // — the completing write fires the next tick, so skipping is safe. + if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return + if (curr.mtimeMs === 0) return + const before = table.get(id)?.entry.rev + let rev: string | undefined + try { + rev = rebuilt(id) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick + deps.onError(error instanceof Error ? error : new Error(String(error))) + return + } + if (rev === undefined || rev === before) return + for (const notify of rebuildListeners) notify(id, rev) + } + watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener) + watched.set(id, { path: record.clientPath, listener }) + } + } + syncWatches() let pending = false const unsubscribe = deps.ctx.on('internal/plugin', () => { @@ -140,8 +263,10 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe pending = false try { table = scan(deps) + graph = composeGraph(table) + syncWatches() } catch (error) { - // Keep serving the previous table: a mid-flight rescan failure must not + // Keep serving the previous graph: a mid-flight rescan failure must not // take down the boot manifest for plugins that were fine. deps.onError(error instanceof Error ? error : new Error(String(error))) } @@ -149,13 +274,23 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe }) return { - snapshot: () => [...table.values()].map(record => record.entry), + graph: () => graph, clientPath: id => table.get(id)?.clientPath, - dispose: () => { unsubscribe() }, + rebuilt, + onRebuilt: (listener) => { + rebuildListeners.add(listener) + return () => { rebuildListeners.delete(listener) } + }, + dispose: () => { + unsubscribe() + for (const { path, listener } of watched.values()) unwatchFile(path, listener) + watched.clear() + rebuildListeners.clear() + }, } } -/** One full table build from the loader's current entries. */ +/** One full table build from the loader's current entries (bundle content is hashed here — an unreadable bundle throws). */ function scan(deps: WebPluginRegistryDeps): Map { const table = new Map() for (const entry of deps.loader.entries()) { @@ -170,15 +305,9 @@ function scan(deps: WebPluginRegistryDeps): Map { if (clientRel === undefined) { throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`) } - table.set(name, { - entry: { - id: name, - url: `/plugins/${name}/client.js`, - inject: decl.inject ?? [], - ...(decl.immediately === true ? { immediately: true } : {}), - }, - clientPath: join(dirname(pkgPath), clientRel), - }) + const clientPath = join(dirname(pkgPath), clientRel) + const rev = shortHash(readFileSync(clientPath)) + table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath }) } return table } diff --git a/packages/host/webserver/tests/invariant.spec.ts b/packages/host/webserver/tests/invariant.spec.ts index 8fddba992a..f9d5ba4490 100644 --- a/packages/host/webserver/tests/invariant.spec.ts +++ b/packages/host/webserver/tests/invariant.spec.ts @@ -1,7 +1,7 @@ /** - * Webserver invariant companion: the boot-manifest consistency audit — every - * registry snapshot row must resolve a clientPath, checked on fiber lifecycle - * events against the assembly-published 'webPlugins' context key. + * Webserver invariant companion: the boot-graph consistency audit — every + * fetch-arrival graph row must resolve a clientPath, checked on fiber + * lifecycle events against the assembly-published 'webPlugins' context key. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants' import * as WebserverInvariant from '../src/invariant.ts' interface RegistryStub { - snapshot(): { id: string; url: string }[] + graph(): { entries: { id: string; url: string }[] } clientPath(id: string): string | undefined } @@ -33,18 +33,18 @@ describe('webserver manifest invariant', () => { expect(() => { trigger(bare) }).not.toThrow() // no 'webPlugins' key published const consistent = await setup({ - snapshot: () => [{ id: 'p1', url: '/plugins/p1/client.js' }], - clientPath: () => '/tmp/p1/lib/client.js', + graph: () => ({ entries: [{ id: 'p1', url: '/plugins/p1/client.js?rev=abc' }] }), + clientPath: id => id === 'p1' ? '/tmp/p1/lib/client.js' : undefined, }) expect(() => { trigger(consistent) }).not.toThrow() }) - it('throws on a manifest row whose bundle path no longer resolves', async () => { + it('throws on a graph row whose bundle path no longer resolves', async () => { const ctx = await setup({ - snapshot: () => [{ id: 'ghost', url: '/plugins/ghost/client.js' }], + graph: () => ({ entries: [{ id: 'ghost', url: '/plugins/ghost/client.js?rev=abc' }] }), clientPath: () => undefined, }) expect(() => { trigger(ctx) }) - .toThrow(/manifest row "ghost".*resolves no client bundle path/) + .toThrow(/graph row "ghost".*resolves no client bundle path/) }) }) diff --git a/packages/host/webserver/tests/web-plugins.spec.ts b/packages/host/webserver/tests/web-plugins.spec.ts index bb08f2ac11..b9efb1c5c9 100644 --- a/packages/host/webserver/tests/web-plugins.spec.ts +++ b/packages/host/webserver/tests/web-plugins.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts' import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts' @@ -25,6 +25,7 @@ interface Fixture { entries: LoaderEntryView[] errors: Error[] ctx: Context + root: string } function makeDeps( @@ -48,32 +49,30 @@ function makeDeps( }, onError: err => void errors.push(err), } - return { deps, entries, errors, ctx } + return { deps, entries, errors, ctx, root } } describe('createHostWebPluginRegistry', () => { - it('collects loaded web-declared plugins with url/inject/immediately and client paths', () => { + it('discovers dshClient rows with rev-stamped urls, manifest inject edges, and the declared immediately mark', () => { const { deps } = makeDeps([ { name: '@deepseek-ai/dsh-client-connection', pkg: webDecl({ immediately: true }) }, { name: '@deepseek-ai/dsh-client-ui-layout', pkg: webDecl({ inject: ['@deepseek-ai/dsh-client-runtime'] }) }, { name: '@deepseek-ai/dsh-agent', pkg: { exports: { '.': './lib/index.js' } } }, // no dshClient: skipped ]) const registry = createHostWebPluginRegistry(deps) - const rows = registry.snapshot() - expect(rows).toEqual([ - { - id: '@deepseek-ai/dsh-client-connection', - url: '/plugins/@deepseek-ai/dsh-client-connection/client.js', - inject: [], - immediately: true, - }, - { - id: '@deepseek-ai/dsh-client-ui-layout', - url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', - inject: ['@deepseek-ai/dsh-client-runtime'], - }, - ]) - expect(registry.clientPath('@deepseek-ai/dsh-client-connection')).toMatch(/lib[/\\]client\.js$/) + const graph = registry.graph() + expect(graph.rev).toMatch(/^[0-9a-f]{12}$/) + const connection = graph.entries[0] + expect(connection?.id).toBe('@deepseek-ai/dsh-client-connection') + expect(connection?.rev).toMatch(/^[0-9a-f]{12}$/) + expect(connection?.url).toBe(`/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=${connection?.rev ?? ''}`) + expect(connection?.immediately).toBe(true) + const layout = graph.entries[1] + expect(layout?.id).toBe('@deepseek-ai/dsh-client-ui-layout') + expect(layout?.inject).toEqual(['@deepseek-ai/dsh-client-runtime']) + expect(layout?.immediately).toBeUndefined() + expect(graph.entries).toHaveLength(2) + expect(registry.clientPath('@deepseek-ai/dsh-client-ui-layout')).toMatch(/lib[/\\]client\.js$/) expect(registry.clientPath('@deepseek-ai/dsh-agent')).toBeUndefined() registry.dispose() }) @@ -85,7 +84,7 @@ describe('createHostWebPluginRegistry', () => { { name: 'electron-only', pkg: { dshClient: { platform: 'electron' }, exports: { './client': './lib/client.js' } } }, ]) const registry = createHostWebPluginRegistry(deps) - expect(registry.snapshot()).toEqual([]) + expect(registry.graph().entries).toEqual([]) registry.dispose() }) @@ -96,6 +95,11 @@ describe('createHostWebPluginRegistry', () => { expect(() => createHostWebPluginRegistry(deps)).toThrow(/declares dshClient but exports no/) }) + it('fails loud at build time on a registered bundle that is not built (rev hashing reads the file)', () => { + const { deps } = makeDeps([{ name: 'unbuilt', pkg: webDecl(), withBundle: false }]) + expect(() => createHostWebPluginRegistry(deps)).toThrow(/ENOENT/) + }) + it('fails loud on malformed declaration fields', () => { for (const dshClient of [42, { platform: 7 }, { platform: 'web', inject: 'nope' }, { platform: 'web', immediately: 'yes' }]) { const { deps } = makeDeps([{ name: 'bad', pkg: { dshClient, exports: { './client': './lib/client.js' } } }]) @@ -103,26 +107,74 @@ describe('createHostWebPluginRegistry', () => { } }) - it('rescans on internal/plugin (debounced) and keeps the old table when a rescan fails', async () => { + it('rebuilt(id) re-hashes the bundle, updates the row and graph rev, and keeps the immediately mark', () => { + const { deps, root } = makeDeps([{ name: 'hot', pkg: webDecl({ immediately: true }) }]) + const registry = createHostWebPluginRegistry(deps) + const before = registry.graph() + const beforeRow = before.entries.find(e => e.id === 'hot') + writeFileSync(join(root, 'hot', 'lib', 'client.js'), '// rebuilt bundle contents') + const rev = registry.rebuilt('hot') + expect(rev).toMatch(/^[0-9a-f]{12}$/) + expect(rev).not.toBe(beforeRow?.rev) + const after = registry.graph() + const afterRow = after.entries.find(e => e.id === 'hot') + expect(afterRow?.rev).toBe(rev) + expect(afterRow?.url).toBe(`/plugins/hot/client.js?rev=${rev ?? ''}`) + expect(afterRow?.immediately).toBe(true) + expect(after.rev).not.toBe(before.rev) + // Unknown ids are not rebuildable. + expect(registry.rebuilt('nope')).toBeUndefined() + registry.dispose() + }) + + it('watch mode: a bundle content change re-hashes the row and notifies onRebuilt; dispose stops the watch', async () => { + const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }]) + deps.watch = { intervalMs: 20 } + const registry = createHostWebPluginRegistry(deps) + const before = registry.graph().entries[0]?.rev + const rebuilds: { id: string; rev: string }[] = [] + registry.onRebuilt((id, rev) => rebuilds.push({ id, rev })) + + writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// new bundle contents') + await vi.waitFor(() => { expect(rebuilds).toHaveLength(1) }, { timeout: 5000 }) + expect(rebuilds[0]?.id).toBe('watched') + expect(rebuilds[0]?.rev).not.toBe(before) + expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev) + + registry.dispose() + writeFileSync(join(root, 'watched', 'lib', 'client.js'), '// post-dispose contents') + await new Promise((resolve) => { setTimeout(resolve, 100) }) + expect(rebuilds).toHaveLength(1) + }) + + it('rejects a non-positive or non-integer watch interval at build time', () => { + for (const intervalMs of [0, -5, 1.5]) { + const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }]) + deps.watch = { intervalMs } + expect(() => createHostWebPluginRegistry(deps)).toThrow(/watch\.intervalMs/) + } + }) + + it('rescans on internal/plugin (debounced) and keeps the old graph when a rescan fails', async () => { const { deps, entries, errors, ctx } = makeDeps([ { name: 'late-loader', pkg: webDecl(), loaded: false }, ]) const registry = createHostWebPluginRegistry(deps) - expect(registry.snapshot()).toEqual([]) + expect(registry.graph().entries).toEqual([]) // Entry finishes loading; a fiber lifecycle event triggers the debounced rescan. ;(entries[0] as { fiber?: unknown }).fiber = {} ctx.emit('internal/plugin', ctx.fiber) ctx.emit('internal/plugin', ctx.fiber) // debounce: two emissions, one rescan await Promise.resolve() - expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) + expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) - // A failing rescan reports the error and keeps serving the previous table. + // A failing rescan reports the error and keeps serving the previous graph. entries.push({ options: { name: 'ghost' }, fiber: {}, disabled: false }) ctx.emit('internal/plugin', ctx.fiber) await Promise.resolve() expect(errors).toHaveLength(1) - expect(registry.snapshot().map(row => row.id)).toEqual(['late-loader']) + expect(registry.graph().entries.map(row => row.id)).toEqual(['late-loader']) // After dispose, further fiber events no longer rescan. registry.dispose() @@ -134,16 +186,19 @@ describe('createHostWebPluginRegistry', () => { }) describe('injectBootManifest', () => { - it('injects the manifest as the first script inside and escapes breakouts', () => { + it('injects the graph as the first script inside and escapes breakouts', () => { const html = '' - const out = injectBootManifest(html, [{ id: 'x