Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**`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.

**`@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.
**`@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.

## Stack & Structure

Expand Down
5 changes: 4 additions & 1 deletion examples/minimal-next-devframe-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico

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`.

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.

## What the example proves

- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly
- `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
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — byte-for-byte the same protocol the Vite host speaks
- `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

## Hosting built-in plugins in a bundler

Expand All @@ -32,7 +35,7 @@ The plugins run node-side (child processes, the native `node-pty` PTY backend) a

| File | Role |
|---|---|
| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — hub context, static-mount registry, side-car WS |
| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — hub context, static-mount registry (incl. the a11y agent), side-car WS |
| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Boots the singleton host and serves `/__hub/__connection.json` |
| `src/client/app/%5F_[id]/[[...path]]/route.ts` | Serves each mounted SPA and its connection meta under `/__<id>/` |
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol |
Expand Down
12 changes: 10 additions & 2 deletions examples/minimal-next-devframe-hub/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
DevframeMessageEntry,
DevframeTerminalSession,
} from '@devframes/hub/types'
import { connectDevframe } from '@devframes/hub/client'
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { iconClass } from './icons'

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

// Boot the framework-level client host: it builds the shared client
// context and imports each dock's client script into this page — e.g.
// the a11y inspector's in-page agent, which then scans this hub live.
const clientHost = await createDevframeClientHost({ rpc })

const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
'devframe:docks',
{ initialValue: [] },
Expand Down Expand Up @@ -97,7 +102,10 @@ export default function Page() {
void refreshTerminals()
}, 2000)

cleanup = () => window.clearInterval(interval)
cleanup = () => {
window.clearInterval(interval)
clientHost.dispose()
}
}
catch (err) {
if (!cancelled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
import { startHttpAndWs } from 'devframe/node'
import { getPort } from 'get-port-please'
import { join } from 'pathe'
import { dirname, join } from 'pathe'
import demoDevframe from './demo-devframe'
import demoDevframeB from './demo-devframe-b'

Expand Down Expand Up @@ -40,6 +40,41 @@ async function loadBuiltinPlugins(): Promise<DevframeDefinition[]> {
return mods.map(mod => mod.default as DevframeDefinition)
}

/** URL base the a11y agent module is served under (same-origin, catch-all route). */
const A11Y_AGENT_MOUNT_BASE = '/__df-a11y-agent/'

interface A11yAgentMount {
/** The a11y devframe's dock id — the dock the client script attaches to. */
dockId: string
/** On-disk directory holding the built agent module. */
dir: string
/** Same-origin URL of the agent module, importable by the hub client runtime. */
importFrom: string
}

/**
* Locate the a11y inspector's in-page **agent** module so the hub can serve it
* same-origin and attach it to the a11y dock as its client script — the hub
* client runtime (booted in `app/page.tsx`) imports it into the host page,
* where it scans this hub live. Loaded through the same bundler-ignored dynamic
* `import()` as the plugins, since the package resolves its `dist` via
* `import.meta.url`. Returns `null` if unavailable.
*/
async function loadA11yAgentMount(): Promise<A11yAgentMount | null> {
try {
const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@devframes/plugin-a11y')
const bundle = mod.a11yAgentBundlePath as string
return {
dockId: (mod.default as DevframeDefinition).id,
dir: dirname(bundle),
importFrom: `${A11Y_AGENT_MOUNT_BASE}inject.js`,
}
}
catch {
return null
}
}

const STATIC_MOUNTS = new Map<string, string>()

export interface StaticMountHit {
Expand Down Expand Up @@ -178,8 +213,20 @@ export async function minimalNextDevframeHub(
description: `Side-car WS on port ${port}. ${devframes.length} devframe(s) registered.`,
})

// Serve the a11y inspector's in-page agent same-origin (via the catch-all
// route) and attach it to the a11y dock as its client script. The hub client
// runtime booted in `app/page.tsx` imports it into the host page, where it
// scans this hub live; the panel iframe shares the origin, so their
// BroadcastChannel connects.
const a11yAgent = await loadA11yAgentMount()
if (a11yAgent)
host.mountStatic(A11Y_AGENT_MOUNT_BASE, a11yAgent.dir)

for (const def of devframes) {
await mountDevframe(context, def)
const clientScript = a11yAgent && def.id === a11yAgent.dockId
? { importFrom: a11yAgent.importFrom }
: undefined
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
}

const started = await startHttpAndWs({
Expand Down
5 changes: 4 additions & 1 deletion examples/minimal-vite-devframe-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ Open the printed URL. The dock on the left lists every mounted tool with its ico

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`.

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.

## What the example proves

- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs framework specifics (static mounts, connection meta, storage, origin) in uniformly
- `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
- 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
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — no hub classes imported on the client
- `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

## Build your own

Expand All @@ -34,7 +37,7 @@ The dock UI is plain DOM in `src/client/`. To skin your own viewer, read the sam
| File | Role |
|---|---|
| `src/minimal-vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
| `vite.config.ts` | Mounts the built-in plugins via the host's `devframes` option |
| `vite.config.ts` | Mounts the built-in plugins via the host's `devframes` option; attaches the a11y agent as its dock's `clientScript` |
| `src/client/main.ts` | The browser UI that consumes the hub protocol |
| `src/client/icons.ts` | Offline Phosphor icons for the dock |
| `index.html` | The UI shell |
8 changes: 7 additions & 1 deletion examples/minimal-vite-devframe-hub/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
DevframeMessageEntry,
DevframeTerminalSession,
} from '@devframes/hub/types'
import { connectDevframe } from '@devframes/hub/client'
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
import { iconClass } from './icons'
import 'virtual:uno.css'
import '@antfu/design/styles.css'
Expand Down Expand Up @@ -53,6 +53,12 @@ async function main() {
const rpc = await connectDevframe({ baseURL: HUB_BASE })
setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')

// Boot the framework-level client host: it builds the shared client context
// and imports each dock's client script into this page — e.g. the a11y
// inspector's in-page agent, which then scans this host live. The dock UI
// below still reads the same shared state directly.
await createDevframeClientHost({ rpc })

// 1. Docks — read from `devframe:docks` shared state.
const docks = await rpc.sharedState.get<DevframeDockEntry[]>(
'devframe:docks',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DevframeHubContext } from '@devframes/hub/node'
import type { ClientScriptEntry } from '@devframes/hub/types'
import type { DevframeDefinition, DevframeHost } from 'devframe/types'
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { homedir } from 'node:os'
Expand All @@ -17,6 +18,12 @@ export interface MinimalViteDevframeHubOptions {
port?: number
/** Devframes to mount as docks. */
devframes?: DevframeDefinition[]
/**
* Per-dock client scripts, keyed by devframe id. Attached to the mounted
* iframe dock so the hub client runtime imports them into the host page
* (e.g. the a11y inspector's in-page agent).
*/
clientScripts?: Record<string, ClientScriptEntry>
}

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

for (const def of options.devframes ?? []) {
await mountDevframe(context, def)
const clientScript = options.clientScripts?.[def.id]
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
}

started = await startHttpAndWs({
Expand Down
9 changes: 8 additions & 1 deletion examples/minimal-vite-devframe-hub/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import a11yDevframe from '@devframes/plugin-a11y'
import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
import codeServerDevframe from '@devframes/plugin-code-server'
import gitDevframe from '@devframes/plugin-git'
import inspectDevframe from '@devframes/plugin-inspect'
Expand Down Expand Up @@ -26,6 +26,13 @@ export default defineConfig({
inspectDevframe,
a11yDevframe,
],
// Attach the a11y inspector's in-page agent as its dock's client script.
// The hub client runtime (booted in src/client/main.ts) imports it into
// this page so the docked panel scans the host live — no bespoke
// injection plugin needed. `/@fs/` lets Vite serve the built module.
clientScripts: {
[a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` },
},
}),
],
})
160 changes: 160 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { DevframeRpcClient } from 'devframe/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { DevframeDockEntry } from '../../types/docks'
import { createEventEmitter } from 'devframe/utils/events'
import { describe, expect, it, vi } from 'vitest'
import { getDevframeClientContext } from '../context'
import { createDevframeClientHost } from '../host'

interface StubSharedState<T> extends SharedState<T> {
/** Replace the state wholesale and emit `updated` (simulates a server patch). */
push: (next: T) => void
}

function createStubSharedState<T>(initial: T): StubSharedState<T> {
let state = initial
const events = createEventEmitter<any>()
return {
value: () => state as any,
on: events.on,
mutate: (fn) => {
fn(state)
events.emit('updated', state, undefined, 'test')
},
patch: () => {},
syncIds: new Set(),
push(next) {
state = next
events.emit('updated', state, undefined, 'test')
},
}
}

function createStubRpc() {
const calls: any[][] = []
const states = new Map<string, StubSharedState<any>>()
const rpc = {
sharedState: {
async get(key: string, options?: { initialValue?: any }) {
if (!states.has(key))
states.set(key, createStubSharedState(options?.initialValue))
return states.get(key)!
},
},
call: async (...args: any[]) => {
calls.push(args)
return `rpc:${args[0]}`
},
} as unknown as DevframeRpcClient
return { rpc, calls, states }
}

function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
return { id, title: id, icon: 'ph:cube', type: 'iframe', url: `/__${id}/`, ...extra } as DevframeDockEntry
}

describe('createDevframeClientHost', () => {
it('publishes the global client context with the full surface', async () => {
const { rpc } = createStubRpc()
const host = await createDevframeClientHost({ rpc })

expect(getDevframeClientContext()).toBe(host.context)
expect(host.context.clientType).toBe('standalone')
expect(host.context.rpc).toBe(rpc)
expect(host.context.panel.store.open).toBe(true)
expect(host.context.when.context).toMatchObject({
clientType: 'standalone',
dockOpen: true,
paletteOpen: false,
dockSelectedId: '',
})
host.dispose()
})

it('reconciles dock entries from shared state and tracks per-entry state', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
const docksState = states.get('devframe:docks')!

docksState.push([iframeEntry('one'), iframeEntry('two')])
expect(host.context.docks.entries.map(e => e.id)).toEqual(['one', 'two'])
expect(host.context.docks.getStateById('one')?.entryMeta.id).toBe('one')

docksState.push([iframeEntry('two')])
expect(host.context.docks.entries.map(e => e.id)).toEqual(['two'])
expect(host.context.docks.getStateById('one')).toBeUndefined()
host.dispose()
})

it('switches entries with activation/deactivation events and when-context updates', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
states.get('devframe:docks')!.push([iframeEntry('one'), iframeEntry('two')])

const activated: string[] = []
const deactivated: string[] = []
for (const id of ['one', 'two']) {
const state = host.context.docks.getStateById(id)!
state.events.on('entry:activated', () => activated.push(id))
state.events.on('entry:deactivated', () => deactivated.push(id))
}

expect(await host.context.docks.switchEntry('one')).toBe(true)
expect(host.context.docks.selected?.id).toBe('one')
expect(host.context.docks.getStateById('one')?.isActive).toBe(true)
expect(host.context.when.context.dockSelectedId).toBe('one')

expect(await host.context.docks.switchEntry('one')).toBe(false) // no-op
expect(await host.context.docks.toggleEntry('two')).toBe(true)
expect(await host.context.docks.toggleEntry('two')).toBe(true) // toggles off
expect(activated).toEqual(['one', 'two'])
expect(deactivated).toEqual(['one', 'two'])
expect(host.context.docks.selected).toBeNull()
host.dispose()
})

it('executes client commands locally and server commands over hub:commands:execute', async () => {
const { rpc, calls } = createStubRpc()
const host = await createDevframeClientHost({ rpc })

const ran: any[] = []
const off = host.context.commands.register({
id: 'test:client',
title: 'Client',
source: 'client',
action: (...args: any[]) => {
ran.push(args)
},
})

await host.context.commands.execute('test:client', 1, 2)
expect(ran).toEqual([[1, 2]])

await expect(host.context.commands.execute('srv:cmd', 'x')).resolves.toBe('rpc:hub:commands:execute')
expect(calls.at(-1)).toEqual(['hub:commands:execute', 'srv:cmd', 'x'])

off()
expect(host.context.commands.commands.find(c => c.id === 'test:client')).toBeUndefined()
host.dispose()
})

it('imports a dock entry client script and hands it the script context', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })

const received: any[] = []
;(globalThis as any).__DF_TEST_SCRIPT__ = (ctx: any) => received.push(ctx)
const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_SCRIPT__(ctx)`
states.get('devframe:docks')!.push([
iframeEntry('scripted', { clientScript: { importFrom: dataUrl } }),
])

await vi.waitFor(() => expect(received).toHaveLength(1))
const scriptCtx = received[0]
expect(scriptCtx.current.entryMeta.id).toBe('scripted')
expect(scriptCtx.rpc).toBe(rpc)
expect(typeof scriptCtx.messages.add).toBe('function')
delete (globalThis as any).__DF_TEST_SCRIPT__
host.dispose()
})
})
Loading
Loading