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 examples/a11y-messages-playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ the focused dock — the same path a manual dock click takes.

| File | Role |
|---|---|
| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS, instance-registry registration |
| `vite.config.ts` | Mounts a11y + messages; attaches the a11y agent as its dock's `clientScript` |
| `src/client/main.ts` | Boots the client host, renders the dock rail + iframe stage |
| `src/client/app-under-test.ts` | The intentionally-broken, multi-route app the agent scans |
Expand Down
41 changes: 39 additions & 2 deletions examples/a11y-messages-playground/src/a11y-messages-playground.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { DevframeHubContext } from '@devframes/hub/node'
import type { ClientScriptEntry } from '@devframes/hub/types'
import type { DevframeInstanceRegistration } from 'devframe/node'
import type { DevframeDefinition, DevframeHost } from 'devframe/types'
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { homedir } from 'node:os'
import process from 'node:process'
import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
import { startHttpAndWs } from 'devframe/node'
import { registerDevframeInstance, startHttpAndWs } from 'devframe/node'
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
import { getPort } from 'get-port-please'
import { join } from 'pathe'
Expand Down Expand Up @@ -37,6 +39,7 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
const base = normalizeBase(options.base ?? '/__hub/')
let viteConfig: ResolvedConfig | undefined
let started: { close: () => Promise<void> } | undefined
let registration: DevframeInstanceRegistration | undefined

return {
name: 'a11y-messages-playground',
Expand All @@ -48,9 +51,12 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =

async configureServer(server: ViteDevServer) {
// Vite re-invokes `configureServer` on restart — tear the old server down
// so we don't leak the WS port.
// so we don't leak the WS port, and drop the previous registry record so
// a restart doesn't leave a ghost instance behind.
await started?.close().catch(() => {})
started = undefined
registration?.unregister()
registration = undefined

const cwd = viteConfig!.root
const port = options.port ?? await getPort({ port: 9878, portRange: [9878, 9978] })
Expand Down Expand Up @@ -103,6 +109,37 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
// Tell the hub UI (served at `base`) where to find the WS endpoint.
serveConnectionMeta(base)

// Register this playground in the global instance registry
// (`~/.devframe/instances/`) so discovery tooling — `devframe connect`
// and the inspector's Instances tab — lists it like any standalone
// devframe. See `examples/vite-devframe-hub` for the same pattern.
const register = (): void => {
const origin = host.resolveOrigin()
const url = new URL(origin)
registration = registerDevframeInstance({
pid: process.pid,
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
origin,
basePath: base,
id: 'example:a11y-messages-playground',
name: 'A11y + Messages Playground',
rootDir: cwd,
mcp: null,
startedAt: Date.now(),
})
}
if (server.httpServer?.listening)
register()
else
server.httpServer?.once('listening', register)

const closeStarted = started.close
started.close = async () => {
registration?.unregister()
registration = undefined
await closeStarted()
}

server.httpServer?.once('close', () => {
void started?.close().catch(() => {})
})
Expand Down
4 changes: 3 additions & 1 deletion examples/next-devframe-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's

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.

The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The host registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (e.g. `pnpm --filter vite-devframe-hub dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.

## 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
Expand All @@ -35,7 +37,7 @@ The plugins run node-side (child processes, the native `zigpty` PTY backend) and

| File | Role |
|---|---|
| `src/client/devframe/next-devframe-hub.ts` | The Next host — hub context, static-mount registry (incl. the a11y agent), side-car WS |
| `src/client/devframe/next-devframe-hub.ts` | The Next host — hub context, static-mount registry (incl. the a11y agent), side-car WS, instance-registry registration |
| `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
4 changes: 3 additions & 1 deletion examples/vite-devframe-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's

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.

The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The host registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (`pnpm --filter a11y-messages-playground dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.

## 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
Expand All @@ -36,7 +38,7 @@ The dock UI is plain DOM in `src/client/`. To skin your own viewer, read the sam

| File | Role |
|---|---|
| `src/vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS |
| `src/vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS, instance-registry registration |
| `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 |
Expand Down
45 changes: 43 additions & 2 deletions examples/vite-devframe-hub/src/vite-devframe-hub.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { DevframeHubContext } from '@devframes/hub/node'
import type { ClientScriptEntry } from '@devframes/hub/types'
import type { DevframeInstanceRegistration } from 'devframe/node'
import type { DevframeDefinition, DevframeHost } from 'devframe/types'
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { homedir } from 'node:os'
import process from 'node:process'
import { defineHubRpcFunction } from '@devframes/hub'
import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
import { startHttpAndWs } from 'devframe/node'
import { registerDevframeInstance, startHttpAndWs } from 'devframe/node'
import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static'
import { getPort } from 'get-port-please'
import { join } from 'pathe'
Expand Down Expand Up @@ -74,6 +76,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
const base = normalizeBase(options.base ?? '/__hub/')
let viteConfig: ResolvedConfig | undefined
let started: { close: () => Promise<void> } | undefined
let registration: DevframeInstanceRegistration | undefined

return {
name: 'vite-devframe-hub',
Expand All @@ -85,9 +88,12 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {

async configureServer(server: ViteDevServer) {
// Vite re-invokes `configureServer` on each restart. Tear down the
// previous server so we don't leak the WS port.
// previous server so we don't leak the WS port, and drop the previous
// registry record so a restart doesn't leave a ghost instance behind.
await started?.close().catch(() => {})
started = undefined
registration?.unregister()
registration = undefined

const cwd = viteConfig!.root
// Prefer 9777 but keep booting when it's taken (e.g. a lingering
Expand Down Expand Up @@ -175,6 +181,41 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
// Tell the hub UI (served at `base`) where to find the WS endpoint.
serveConnectionMeta(base)

// Record this hub in the global instance registry (`~/.devframe/instances/`)
// so discovery tooling — `devframe connect` and the inspector's Instances
// tab — lists it like any standalone devframe. `createDevServer` registers
// automatically; an in-process host like this one registers explicitly,
// reusing the Vite dev server's own origin (where `<base>__connection.json`
// is served). Registration waits for the server to be listening so the
// origin/port are known. Folded into `started.close` so every teardown
// path (restart, httpServer close, `closeBundle`) also unregisters.
const register = (): void => {
const origin = host.resolveOrigin()
const url = new URL(origin)
registration = registerDevframeInstance({
pid: process.pid,
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
origin,
basePath: base,
id: 'example:vite-devframe-hub',
name: 'Vite Devframe Hub',
rootDir: cwd,
mcp: null,
startedAt: Date.now(),
})
}
if (server.httpServer?.listening)
register()
else
server.httpServer?.once('listening', register)

const closeStarted = started.close
started.close = async () => {
registration?.unregister()
registration = undefined
await closeStarted()
}

server.httpServer?.once('close', () => {
void started?.close().catch(() => {})
})
Expand Down
8 changes: 5 additions & 3 deletions packages/devframe/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export type { RpcFunctionsHost } from './host-functions'
export * from './host-h3'
export * from './host-services'
export * from './host-views'
// Only registration is public — custom hosts (e.g. @devframes/next) record
// themselves; the read/probe/prune helpers stay internal to the connector.
export { registerDevframeInstance } from './instance-registry'
// Registration is public — custom hosts (e.g. @devframes/next) record
// themselves — and live discovery is public too, so surfaces like the
// inspect plugin's Instances tab can enumerate running instances. The
// lower-level read/probe/prune helpers stay internal to the connector.
export { listLiveDevframeInstances, registerDevframeInstance } from './instance-registry'
export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
export * from './rpc-shared-state'
export * from './rpc-streaming'
Expand Down
13 changes: 9 additions & 4 deletions plugins/inspect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
A devframe plugin that inspects *its own* connection (and, when mounted in a
host, the host's): browse every registered RPC function with its metadata,
invoke read-only `query`/`static` functions and inspect the results, watch
shared-state keys update live, and explore the agent-exposed surface.
shared-state keys update live, explore the agent-exposed surface, and — while
running against a live backend — list the other devframe dev servers running
alongside it.

Ported in spirit from the RPC & State panels of
[`vitejs/devtools`](https://github.com/vitejs/devtools); rebuilt on devframe's
Expand Down Expand Up @@ -54,7 +56,10 @@ All functions are namespaced `devframes:plugin:inspect:*`:
| `invoke` | `action` | Invokes a read-only `query`/`static` function by name and returns a result envelope. Refuses `action`/`event` functions. |
| `list-state-keys` | `query` (snapshot) | The keys of every shared-state entry on the connection. |
| `describe-agent` | `query` (snapshot) | The agent manifest — tools and readable resources. |
| `list-instances` | `query` (live) | Every devframe dev server currently running on the machine, discovered through the shared instance registry. Powers the read-only Instances tab. |

The three `query` functions are agent-exposed (read-only) and bake into the
static dump, so the inspector still lists functions, state keys, and the agent
surface when deployed as a static SPA.
The three snapshot `query` functions are agent-exposed (read-only) and bake into
the static dump, so the inspector still lists functions, state keys, and the
agent surface when deployed as a static SPA. `list-instances` is live rather
than baked (the set of running processes is meaningless in a static dump), so
the Instances tab appears only against a live backend.
2 changes: 1 addition & 1 deletion plugins/inspect/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOpti
import { connectDevframe } from 'devframe/client'

export type { DevframeConnectionStatus, DevframeRpcClient }
export type { AgentManifest, DevframeInspectCommandInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types'
export type { AgentManifest, DevframeInspectCommandInfo, DevframeInspectInstanceInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types'

/**
* Connect to the inspector's devframe backend. A thin, typed wrapper
Expand Down
41 changes: 41 additions & 0 deletions plugins/inspect/src/rpc/functions/list-instances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { DevframeInspectInstanceInfo } from '../../types'
import process from 'node:process'
import { listLiveDevframeInstances } from 'devframe/node'
import { defineInspectRpc } from './_define'

/**
* Enumerate every devframe dev server currently running on this machine,
* discovered through the shared instance registry (`~/.devframe/instances/`)
* with a `__connection.json` liveness probe — the same discovery that backs
* the `devframe connect` bin. Powers the inspector's read-only Instances tab.
*
* Deliberately **not** `snapshot` (the set of live processes is meaningless
* baked into a static dump — the Instances tab is hidden in `build`/`spa`
* mode) and **not** agent-exposed (the `devframe connect` bin already offers
* instance discovery to agents over MCP, so exposing it here would duplicate
* that surface).
*/
export const listInstances = defineInspectRpc({
name: 'devframes:plugin:inspect:list-instances',
type: 'query',
jsonSerializable: true,
setup: () => ({
handler: async (): Promise<DevframeInspectInstanceInfo[]> => {
const { live } = await listLiveDevframeInstances()
const currentPid = process.pid
return live.map(record => ({
id: record.id,
name: record.name,
port: record.port,
origin: record.origin,
basePath: record.basePath,
url: `${record.origin}${record.basePath}`,
pid: record.pid,
rootDir: record.rootDir,
startedAt: record.startedAt,
hasMcp: record.mcp != null,
isCurrent: record.pid === currentPid,
}))
},
}),
})
2 changes: 2 additions & 0 deletions plugins/inspect/src/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { invoke } from './functions/invoke'
import { invokeAgentTool } from './functions/invoke-agent-tool'
import { listCommands } from './functions/list-commands'
import { listFunctions } from './functions/list-functions'
import { listInstances } from './functions/list-instances'
import { listStateKeys } from './functions/list-state-keys'
import { readAgentResource } from './functions/read-agent-resource'

Expand All @@ -21,6 +22,7 @@ export const serverFunctions = [
readAgentResource,
listCommands,
executeCommand,
listInstances,
] as const

declare module 'devframe' {
Expand Down
14 changes: 11 additions & 3 deletions plugins/inspect/src/spa/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import AgentSmart from './components/AgentSmart.vue'
import CommandsSmart from './components/CommandsSmart.vue'
import FunctionsSmart from './components/FunctionsSmart.vue'
import HistorySmart from './components/HistorySmart.vue'
import InstancesSmart from './components/InstancesSmart.vue'
import StateSmart from './components/StateSmart.vue'
import { useRefresh } from './composables/refresh'
import { connect, connection } from './composables/rpc'
import { connect, connection, isStatic } from './composables/rpc'

type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history'
type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' | 'instances'

const tab = ref<Tab>('functions')
const { refresh, loading } = useRefresh()
Expand All @@ -34,14 +35,20 @@ const conn = computed(() => connectionIndicator(connection.status))
// is connected.
const connState = computed(() => connectionState(connection.status))

const tabs: { value: Tab, label: string, icon: string }[] = [
const allTabs: { value: Tab, label: string, icon: string }[] = [
{ value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' },
{ value: 'state', label: 'State', icon: 'i-ph-database-duotone' },
{ value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' },
{ value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' },
{ value: 'history', label: 'History', icon: 'i-ph-clock-counter-clockwise-duotone' },
{ value: 'instances', label: 'Instances', icon: 'i-ph-broadcast-duotone' },
]

// The Instances tab lists running devframe dev servers via a live node-side
// RPC — meaningless in a static `build`/`spa` dump (no backend to query), so
// it only appears when connected to a live backend.
const tabs = computed(() => isStatic() ? allTabs.filter(t => t.value !== 'instances') : allTabs)

onMounted(connect)

// The client doesn't auto-reconnect; a reload re-runs the whole handshake.
Expand Down Expand Up @@ -106,6 +113,7 @@ function reload(): void {
<AgentSmart v-else-if="tab === 'agent'" />
<CommandsSmart v-else-if="tab === 'commands'" />
<HistorySmart v-else-if="tab === 'history'" />
<InstancesSmart v-else-if="tab === 'instances'" />
</template>
</main>
</div>
Expand Down
23 changes: 23 additions & 0 deletions plugins/inspect/src/spa/components/InstancesSmart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DevframeInspectInstanceInfo } from '@devframes/plugin-inspect/client'
import { onMounted, shallowRef } from 'vue'
import { useRefreshProvider } from '../composables/refresh'
import { useRpc } from '../composables/rpc'
import InstancesView from './InstancesView.vue'

const rpc = useRpc()
const instances = shallowRef<DevframeInspectInstanceInfo[] | null>(null)

async function fetchData(): Promise<void> {
if (!rpc.value)
return
instances.value = await rpc.value.call('devframes:plugin:inspect:list-instances')
}

useRefreshProvider(fetchData)
onMounted(fetchData)
</script>

<template>
<InstancesView :instances="instances" />
</template>
Loading
Loading