Skip to content

Commit 12b6177

Browse files
committed
feat(hub): headless client host runtime that runs dock client scripts in the host page
Adopt the `@vitejs/devtools-kit` model at the framework level: instead of each plugin shipping its own injection mechanism, the hub now ships a headless client runtime — `createDevframeClientHost()` from `@devframes/hub/client` — that a host page boots once. It assembles the full `DevframeClientContext` (panel, docks, commands, when) from the hub's shared state, publishes it at `__DEVFRAME_HUB_CLIENT_CONTEXT__`, and imports each dock entry's client script (`action` / `custom-render` / iframe `clientScript`) into the page, handing it a `DockClientScriptContext` with per-entry state, events, and a messages client bridged over new `hub:messages:*` built-in RPCs. The a11y inspector becomes the first consumer: its in-page agent is now the a11y dock's client script (`a11yAgentBundlePath`, default-exported for the loader), replacing the bespoke `a11yAgent()` Vite plugin and `A11Y_AGENT_PATH` serving path introduced earlier in this branch. Both minimal hubs boot the runtime from their existing client entry — the Vite host attaches the agent via `/@fs/`, the Next host serves it through its catch-all route — and drop the hand-placed script tags. Created with the help of an agent.
1 parent 7258db5 commit 12b6177

30 files changed

Lines changed: 799 additions & 209 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**`devframe`** is the framework-neutral container for one devtool integration, portable across viewers. Build a single tool (its RPC, its SPA, its diagnostics, its CLI/build/spa/embedded outputs) without caring how it'll be displayed. A devframe app runs standalone (CLI, static deploy, embedded SPA) just as well as it mounts inside a hub.
66

7-
**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI — implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. See `examples/minimal-vite-devframe-hub/` for a working ~120-line Vite host demonstrating the protocol end to end.
7+
**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI — implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. It does ship a **headless client runtime** (`createDevframeClientHost()` from `@devframes/hub/client`): booted in the host page, it assembles the shared `DevframeClientContext` (panel, docks, commands, when) and imports each dock entry's client script (`action` / `custom-render` / iframe `clientScript`) into that page — how a plugin like the a11y inspector runs code inside the page being inspected. See `examples/minimal-vite-devframe-hub/` for a working ~120-line Vite host demonstrating the protocol end to end.
88

99
## Stack & Structure
1010

examples/minimal-next-devframe-hub/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
1818

1919
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`.
2020

21-
The A11y Inspector shows a live axe-core report of this hub's own page: the host serves the plugin's in-page agent bundle (`a11yAgentBundlePath`) same-origin through the catch-all route, and `app/layout.tsx` loads it into the page, so the docked panel and the agent share the origin their BroadcastChannel rides.
21+
The A11y Inspector shows a live axe-core report of this hub's own page: the host serves the plugin's in-page agent module (`a11yAgentBundlePath`) same-origin through the catch-all route and attaches it as the a11y dock's `clientScript`; the hub client runtime — `createDevframeClientHost()` booted in `app/page.tsx` — imports it into the page, so the docked panel and the agent share the origin their BroadcastChannel rides.
2222

2323
## What the example proves
2424

2525
- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly
2626
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock and serves both its SPA and its `__connection.json`, so the embedded SPA connects straight back to the hub
2727
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — byte-for-byte the same protocol the Vite host speaks
28+
- `createDevframeClientHost()` boots the hub's framework-level client runtime in the host page: it publishes the shared client context and imports each dock's `clientScript` (here, the a11y agent) so plugins run code in the page being inspected
2829

2930
## Hosting built-in plugins in a bundler
3031

examples/minimal-next-devframe-hub/src/client/app/layout.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
1717
<head>
1818
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
1919
</head>
20-
<body>
21-
{children}
22-
{/*
23-
Load the a11y inspector agent into the hub's own page so its docked
24-
panel scans the host live. The hub serves this bundle same-origin
25-
(see the agent mount in `devframe/minimal-next-devframe-hub.ts`); the
26-
path mirrors the plugin's `A11Y_AGENT_PATH` constant.
27-
*/}
28-
<script type="module" src="/__df-inject/inject.js" async />
29-
</body>
20+
<body>{children}</body>
3021
</html>
3122
)
3223
}

examples/minimal-next-devframe-hub/src/client/app/page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
DevframeMessageEntry,
88
DevframeTerminalSession,
99
} from '@devframes/hub/types'
10-
import { connectDevframe } from '@devframes/hub/client'
10+
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
1111
import { useEffect, useMemo, useRef, useState } from 'react'
1212
import { iconClass } from './icons'
1313

@@ -57,6 +57,11 @@ export default function Page() {
5757
rpcRef.current = rpc
5858
setStatus({ text: `Connected: backend=${rpc.connectionMeta.backend}`, kind: 'ready' })
5959

60+
// Boot the framework-level client host: it builds the shared client
61+
// context and imports each dock's client script into this page — e.g.
62+
// the a11y inspector's in-page agent, which then scans this hub live.
63+
const clientHost = await createDevframeClientHost({ rpc })
64+
6065
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
6166
'devframe:docks',
6267
{ initialValue: [] },
@@ -97,7 +102,10 @@ export default function Page() {
97102
void refreshTerminals()
98103
}, 2000)
99104

100-
cleanup = () => window.clearInterval(interval)
105+
cleanup = () => {
106+
window.clearInterval(interval)
107+
clientHost.dispose()
108+
}
101109
}
102110
catch (err) {
103111
if (!cancelled)

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,35 @@ async function loadBuiltinPlugins(): Promise<DevframeDefinition[]> {
4040
return mods.map(mod => mod.default as DevframeDefinition)
4141
}
4242

43+
/** URL base the a11y agent module is served under (same-origin, catch-all route). */
44+
const A11Y_AGENT_MOUNT_BASE = '/__df-a11y-agent/'
45+
46+
interface A11yAgentMount {
47+
/** The a11y devframe's dock id — the dock the client script attaches to. */
48+
dockId: string
49+
/** On-disk directory holding the built agent module. */
50+
dir: string
51+
/** Same-origin URL of the agent module, importable by the hub client runtime. */
52+
importFrom: string
53+
}
54+
4355
/**
44-
* Locate the a11y inspector's in-page **agent** bundle so the hub can serve it
45-
* same-origin (see `app/layout.tsx`, which loads it into the host page). Loaded
46-
* through the same bundler-ignored dynamic `import()` as the plugins, since the
47-
* package resolves its `dist` via `import.meta.url`. Returns the conventional
48-
* URL path and the on-disk directory to mount, or `null` if unavailable.
56+
* Locate the a11y inspector's in-page **agent** module so the hub can serve it
57+
* same-origin and attach it to the a11y dock as its client script — the hub
58+
* client runtime (booted in `app/page.tsx`) imports it into the host page,
59+
* where it scans this hub live. Loaded through the same bundler-ignored dynamic
60+
* `import()` as the plugins, since the package resolves its `dist` via
61+
* `import.meta.url`. Returns `null` if unavailable.
4962
*/
50-
async function loadA11yAgentMount(): Promise<{ base: string, dir: string } | null> {
63+
async function loadA11yAgentMount(): Promise<A11yAgentMount | null> {
5164
try {
5265
const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@devframes/plugin-a11y')
53-
const path = mod.A11Y_AGENT_PATH as string
5466
const bundle = mod.a11yAgentBundlePath as string
55-
return { base: path.slice(0, path.lastIndexOf('/') + 1), dir: dirname(bundle) }
67+
return {
68+
dockId: (mod.default as DevframeDefinition).id,
69+
dir: dirname(bundle),
70+
importFrom: `${A11Y_AGENT_MOUNT_BASE}inject.js`,
71+
}
5672
}
5773
catch {
5874
return null
@@ -197,17 +213,21 @@ export async function minimalNextDevframeHub(
197213
description: `Side-car WS on port ${port}. ${devframes.length} devframe(s) registered.`,
198214
})
199215

200-
for (const def of devframes) {
201-
await mountDevframe(context, def)
202-
}
203-
204-
// Serve the a11y inspector agent same-origin so the hub's own page (which
205-
// loads it via app/layout.tsx) is scanned live by the docked panel. The
206-
// catch-all route serves this mount; the panel iframe and the agent share
207-
// the origin, so their BroadcastChannel connects.
216+
// Serve the a11y inspector's in-page agent same-origin (via the catch-all
217+
// route) and attach it to the a11y dock as its client script. The hub client
218+
// runtime booted in `app/page.tsx` imports it into the host page, where it
219+
// scans this hub live; the panel iframe shares the origin, so their
220+
// BroadcastChannel connects.
208221
const a11yAgent = await loadA11yAgentMount()
209222
if (a11yAgent)
210-
host.mountStatic(a11yAgent.base, a11yAgent.dir)
223+
host.mountStatic(A11Y_AGENT_MOUNT_BASE, a11yAgent.dir)
224+
225+
for (const def of devframes) {
226+
const clientScript = a11yAgent && def.id === a11yAgent.dockId
227+
? { importFrom: a11yAgent.importFrom }
228+
: undefined
229+
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
230+
}
211231

212232
const started = await startHttpAndWs({
213233
context,

examples/minimal-vite-devframe-hub/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico
1818

1919
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`.
2020

21-
The A11y Inspector shows a live axe-core report of this hub's own page: `a11yAgent()` (in `vite.config.ts`) serves the plugin's in-page agent and injects it into `index.html`, so the docked panel and the agent share the Vite origin their BroadcastChannel rides. Hover a violation to ring the offending element in the hub UI.
21+
The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the plugin's in-page agent as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime — `createDevframeClientHost()` booted in `src/client/main.ts` — imports it into the host page. Panel and agent share the Vite origin their BroadcastChannel rides; hover a violation to ring the offending element in the hub UI.
2222

2323
## What the example proves
2424

2525
- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs framework specifics (static mounts, connection meta, storage, origin) in uniformly
2626
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock and serves both its SPA and its `__connection.json`, so the embedded SPA connects straight back to the hub
2727
- Real integrations work end to end through the mount path — the inspector lists every plugin's RPC functions live, terminals stream over the hub, and code-server launches an authenticated editor
2828
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — no hub classes imported on the client
29+
- `createDevframeClientHost()` boots the hub's framework-level client runtime in the host page: it publishes the shared client context and imports each dock's `clientScript` (here, the a11y agent) so plugins run code in the page being inspected
2930

3031
## Build your own
3132

@@ -36,7 +37,7 @@ The dock UI is plain DOM in `src/client/`. To skin your own viewer, read the sam
3637
| File | Role |
3738
|---|---|
3839
| `src/minimal-vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
39-
| `vite.config.ts` | Mounts the built-in plugins via the host's `devframes` option; adds `a11yAgent()` to load the a11y agent into the host page |
40+
| `vite.config.ts` | Mounts the built-in plugins via the host's `devframes` option; attaches the a11y agent as its dock's `clientScript` |
4041
| `src/client/main.ts` | The browser UI that consumes the hub protocol |
4142
| `src/client/icons.ts` | Offline Phosphor icons for the dock |
4243
| `index.html` | The UI shell |

examples/minimal-vite-devframe-hub/src/client/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
DevframeMessageEntry,
55
DevframeTerminalSession,
66
} from '@devframes/hub/types'
7-
import { connectDevframe } from '@devframes/hub/client'
7+
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
88
import { iconClass } from './icons'
99
import 'virtual:uno.css'
1010
import '@antfu/design/styles.css'
@@ -53,6 +53,12 @@ async function main() {
5353
const rpc = await connectDevframe({ baseURL: HUB_BASE })
5454
setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')
5555

56+
// Boot the framework-level client host: it builds the shared client context
57+
// and imports each dock's client script into this page — e.g. the a11y
58+
// inspector's in-page agent, which then scans this host live. The dock UI
59+
// below still reads the same shared state directly.
60+
await createDevframeClientHost({ rpc })
61+
5662
// 1. Docks — read from `devframe:docks` shared state.
5763
const docks = await rpc.sharedState.get<DevframeDockEntry[]>(
5864
'devframe:docks',

examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { ClientScriptEntry } from '@devframes/hub/types'
23
import type { DevframeDefinition, DevframeHost } from 'devframe/types'
34
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
45
import { homedir } from 'node:os'
@@ -17,6 +18,12 @@ export interface MinimalViteDevframeHubOptions {
1718
port?: number
1819
/** Devframes to mount as docks. */
1920
devframes?: DevframeDefinition[]
21+
/**
22+
* Per-dock client scripts, keyed by devframe id. Attached to the mounted
23+
* iframe dock so the hub client runtime imports them into the host page
24+
* (e.g. the a11y inspector's in-page agent).
25+
*/
26+
clientScripts?: Record<string, ClientScriptEntry>
2027
}
2128

2229
// Minimal hub-local RPCs — used by the UI for read-side data. A more
@@ -142,7 +149,8 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
142149
})
143150

144151
for (const def of options.devframes ?? []) {
145-
await mountDevframe(context, def)
152+
const clientScript = options.clientScripts?.[def.id]
153+
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
146154
}
147155

148156
started = await startHttpAndWs({

examples/minimal-vite-devframe-hub/vite.config.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import a11yDevframe from '@devframes/plugin-a11y'
2-
import { a11yAgent } from '@devframes/plugin-a11y/vite'
1+
import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
32
import codeServerDevframe from '@devframes/plugin-code-server'
43
import gitDevframe from '@devframes/plugin-git'
54
import inspectDevframe from '@devframes/plugin-inspect'
@@ -15,10 +14,6 @@ export default defineConfig({
1514
resolve: { alias },
1615
plugins: [
1716
UnoCSS(),
18-
// Load the a11y inspector agent into this hub's own page so its docked
19-
// panel scans the host live. The panel (mounted below) and this agent
20-
// share the Vite origin, so their BroadcastChannel connects.
21-
a11yAgent(),
2217
minimalViteDevframeHub({
2318
devframes: [
2419
demoDevframe,
@@ -31,6 +26,13 @@ export default defineConfig({
3126
inspectDevframe,
3227
a11yDevframe,
3328
],
29+
// Attach the a11y inspector's in-page agent as its dock's client script.
30+
// The hub client runtime (booted in src/client/main.ts) imports it into
31+
// this page so the docked panel scans the host live — no bespoke
32+
// injection plugin needed. `/@fs/` lets Vite serve the built module.
33+
clientScripts: {
34+
[a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` },
35+
},
3436
}),
3537
],
3638
})

0 commit comments

Comments
 (0)