refactor(gui): host graph from dshClient discovery; webserver self-watches bundles for HMR
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.
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user