Merge remote-tracking branch 'origin/feat/send-unify' into xtr/agent-loop-message-machine

This commit is contained in:
_Kerman
2026-07-24 16:31:49 +08:00
162 changed files with 5245 additions and 2329 deletions

View File

@@ -39,7 +39,7 @@ export type {
} from './rpc.ts'
// ---- Errors and ids ----
export { RpcId } from './rpc.ts'
export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
// ---- Method registry and derived generics ----

View File

@@ -50,6 +50,20 @@ export type RpcError = {
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
* carrier consumer folds the same way.
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}
/**
* Signature-layer narrow form, request side (domain-interface view, shared by
* both directions): rpcId is explicit in the signature, never mixed into the

View File

@@ -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:^",
@@ -73,8 +64,8 @@
"@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:^"
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",

View File

@@ -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'

View File

@@ -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<MountedWebPlugins> {
export async function mountWebPlugins(
ctx: Context, plugins: readonly string[], anchor: string,
): Promise<MountedWebPlugins> {
// 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`),

View File

@@ -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<Context> {
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)
})
})

View File

@@ -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/')
})
})

View File

@@ -68,9 +68,6 @@
{
"path": "../../fs/tool-fs-search"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../llm/token-meter"
},
@@ -126,31 +123,10 @@
"path": "../../../vendor/loader"
},
{
"path": "../../client/connection"
"path": "../../context/workspace-context"
},
{
"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"
"path": "../../ui/user-interaction"
}
]
}

View File

@@ -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/<id>/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/<id>/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<HostWebPluginRegistry, 'snapshot' | 'clientPath'>
webPlugins?: Pick<HostWebPluginRegistry, 'graph' | 'clientPath' | 'onRebuilt'>
}
/** 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<string> => {
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<void> => {
/* 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<void> | undefined
const close = (): Promise<void> => (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 <head> (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 <head> (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 = `<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
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<HostWebPluginRegistry, 'clientPath'>,
): Promise<void> {
@@ -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.

View File

@@ -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/<id>/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/<id>/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 })

View File

@@ -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<ServerResponse>()
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)
},
}
}

View File

@@ -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/<id>/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/<id>/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/<id>/client.js`). */
/** Bundle URL served by this webserver (`/plugins/<id>/client.js?rev=<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,102 @@ 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<string, WebPluginRecord>): 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<string, { path: string; listener: (curr: Stats, prev: Stats) => 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) {
// A throwing subscriber must not escape the fs.watchFile callback
// (that would skip later subscribers and can kill the process).
try {
notify(id, rev)
} catch (error) {
deps.onError(error instanceof Error ? error : new Error(String(error)))
}
}
}
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 +271,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 +282,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<string, WebPluginRecord> {
const table = new Map<string, WebPluginRecord>()
for (const entry of deps.loader.entries()) {
@@ -170,15 +313,9 @@ function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
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
}

View File

@@ -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/)
})
})

View File

@@ -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 <head> and escapes </script> breakouts', () => {
it('injects the graph as the first script inside <head> and escapes </script> breakouts', () => {
const html = '<html><head><script src="app.js"></script></head><body></body></html>'
const out = injectBootManifest(html, [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js', inject: [] }])
const out = injectBootManifest(html, {
rev: 'r1',
entries: [{ id: 'x</script><script>alert(1)', url: '/plugins/x/client.js?rev=r2', rev: 'r2' }],
})
expect(out.indexOf('window.__DSH_BOOT__')).toBeLessThan(out.indexOf('app.js'))
expect(out).not.toContain('</script><script>alert(1)')
expect(out).toContain('\\u003c/script')
})
it('prepends when the page has no <head>', () => {
const out = injectBootManifest('<body>x</body>', [])
const out = injectBootManifest('<body>x</body>', { rev: 'r0', entries: [] })
expect(out.startsWith('<script>window.__DSH_BOOT__')).toBe(true)
})
})
@@ -181,7 +236,7 @@ describe('clientExportOf shapes (through the registry build)', () => {
entries.push({ options: { name: 'dup-entry' }, fiber: {}, disabled: false })
void first
const registry = createHostWebPluginRegistry(deps)
expect(registry.snapshot().filter(r => r.id === 'dup-entry')).toHaveLength(1)
expect(registry.graph().entries.filter(r => r.id === 'dup-entry')).toHaveLength(1)
registry.dispose()
})

View File

@@ -179,18 +179,34 @@ describe.skipIf(process.platform === 'win32')('static serving', () => {
})
})
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint)', () => {
const rows = [
{ 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'] },
]
describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injection + bundle endpoint + events channel)', () => {
const FETCH_ID = '@deepseek-ai/dsh-client-ui-layout'
const graphValue = {
rev: 'graphrev00001',
entries: [
{ id: '@deepseek-ai/dsh-client-connection', url: '/plugins/@deepseek-ai/dsh-client-connection/client.js?rev=eeee2222ffff', rev: 'eeee2222ffff', immediately: true },
{ id: FETCH_ID, url: `/plugins/${FETCH_ID}/client.js?rev=aaaa0000bbbb`, rev: 'aaaa0000bbbb', inject: [] },
],
}
async function bootWithPlugins(): Promise<string> {
/** Captures the server's onRebuilt subscription so tests can fire registry notifications by hand. */
interface RebuiltHarness {
notify: (id: string, rev: string) => void
unsubscribed: boolean
}
async function bootWithPlugins(harness?: RebuiltHarness): Promise<string> {
const { distIndex, distRoot } = makeDist()
writeFileSync(join(distRoot, 'bundle.js'), 'window.DSHClientProxy.loadPlugin({})')
const webPlugins = {
snapshot: () => rows,
clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined,
graph: () => graphValue,
clientPath: (id: string) => id === FETCH_ID ? join(distRoot, 'bundle.js') : undefined,
onRebuilt: (listener: (id: string, rev: string) => void) => {
if (harness !== undefined) harness.notify = listener
return () => {
if (harness !== undefined) harness.unsubscribed = true
}
},
}
server = await startWebServer(
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
@@ -198,12 +214,12 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
return `http://127.0.0.1:${String(server.port)}`
}
it('injects window.__DSH_BOOT__ into / and SPA fallbacks; asset requests stay verbatim', async () => {
it('injects the window.__DSH_BOOT__ graph into / and SPA fallbacks; asset requests stay verbatim', async () => {
const base = await bootWithPlugins()
const index = await (await fetch(`${base}/`)).text()
expect(index).toContain('window.__DSH_BOOT__')
const manifest = /window\.__DSH_BOOT__ = (.*?)<\/script>/.exec(index)?.[1]
expect(JSON.parse(manifest ?? '')).toEqual({ plugins: rows })
expect(JSON.parse(manifest ?? '')).toEqual(graphValue)
const fallback = await (await fetch(`${base}/routes/deep/link`)).text()
expect(fallback).toContain('window.__DSH_BOOT__')
@@ -213,11 +229,12 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
expect(await (await fetch(`${base}/app.js`)).text()).toBe('console.log(1)')
})
it('serves registered client bundles and 404s unknown ids (no SPA fallback)', async () => {
it('serves registered client bundles with no-cache (rev query ignored) and 404s unknown ids (no SPA fallback)', async () => {
const base = await bootWithPlugins()
const bundle = await fetch(`${base}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
const bundle = await fetch(`${base}/plugins/${FETCH_ID}/client.js?rev=whatever`)
expect(bundle.status).toBe(200)
expect(bundle.headers.get('content-type')).toBe('text/javascript; charset=utf-8')
expect(bundle.headers.get('cache-control')).toBe('no-cache')
expect(await bundle.text()).toContain('DSHClientProxy')
expect((await fetch(`${base}/plugins/unknown/client.js`)).status).toBe(404)
@@ -226,23 +243,59 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti
it('404s a registered id whose bundle file is unreadable (unbuilt dist must fail loud, not fall back to HTML)', async () => {
const { distIndex } = makeDist()
const webPlugins = {
snapshot: () => rows,
graph: () => graphValue,
clientPath: () => '/nonexistent/lib/client.js',
onRebuilt: () => () => undefined,
}
server = await startWebServer(
{ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined,
)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`)
const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/${FETCH_ID}/client.js`)
expect(res.status).toBe(404)
})
it('keeps both surfaces off without the webPlugins option', async () => {
it('keeps all plugin surfaces off without the webPlugins option', async () => {
const base = await boot()
expect(await (await fetch(`${base}/`)).text()).toBe('<html>INDEX</html>')
// No plugin route: falls through to static SPA fallback semantics.
// No plugin routes: fall through to static SPA fallback semantics.
const res = await fetch(`${base}/plugins/x/client.js`)
expect(res.status).toBe(200)
expect(await res.text()).toBe('<html>INDEX</html>')
const events = await fetch(`${base}/plugins/events`)
expect(await events.text()).toBe('<html>INDEX</html>')
})
it('GET /plugins/events opens SSE with the current graph frame; a registry rebuild notification broadcasts', async () => {
const harness: RebuiltHarness = { notify: () => { throw new Error('onRebuilt never subscribed') }, unsubscribed: false }
const base = await bootWithPlugins(harness)
const events = await fetch(`${base}/plugins/events`)
expect(events.status).toBe(200)
expect(events.headers.get('content-type')).toBe('text/event-stream')
const reader = events.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
async function readUntil(marker: string): Promise<void> {
while (!buffer.includes(marker)) {
const chunk = await reader?.read()
if (chunk?.done !== false) throw new Error('SSE stream ended early')
buffer += decoder.decode(chunk.value, { stream: true })
}
}
await readUntil('"type":"graph"')
expect(buffer).toContain(': connected')
const graphLine = /data: (.*)\n\n/.exec(buffer)?.[1]
expect(JSON.parse(graphLine ?? '')).toEqual({ type: 'graph', graph: graphValue })
// The registry's bundle watch observed a rebuild: the server relays it as an SSE frame.
harness.notify(FETCH_ID, 'cccc1111dddd')
await readUntil('"type":"rebuilt"')
expect(buffer).toContain(JSON.stringify({ type: 'rebuilt', id: FETCH_ID, rev: 'cccc1111dddd' }))
await reader?.cancel()
// Shutdown unsubscribes the relay (no broadcast into a closed channel).
await server?.close()
server = undefined
expect(harness.unsubscribed).toBe(true)
})
})