From adffc6b94331e400507713909b17c908d2e25cbe Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 4 Aug 2026 04:08:37 +0000 Subject: [PATCH 1/2] feat(inspect): add Instances tab listing running devframe dev servers Surface the other devframe instances running on the machine as a read-only directory inside the inspector, mirroring what the `devframe connect` bin discovers. A new node-side query RPC `devframes:plugin:inspect:list-instances` wraps the shared instance registry (`listLiveDevframeInstances`, now public from `devframe/node`); the SPA renders each instance with its SPA URL, port, pid, root and uptime, marks the current process, and pins it first. The RPC is live (not baked into the static dump) and not agent-exposed, so the tab appears only against a live backend and doesn't duplicate the connect bin's MCP discovery. --- packages/devframe/src/node/index.ts | 8 +- plugins/inspect/README.md | 13 ++- plugins/inspect/src/client/index.ts | 2 +- .../src/rpc/functions/list-instances.ts | 41 +++++++++ plugins/inspect/src/rpc/index.ts | 2 + plugins/inspect/src/spa/App.vue | 14 ++- .../src/spa/components/InstancesSmart.vue | 23 +++++ .../spa/components/InstancesView.stories.ts | 70 +++++++++++++++ .../src/spa/components/InstancesView.vue | 87 +++++++++++++++++++ plugins/inspect/src/spa/style.css | 56 ++++++++++++ plugins/inspect/src/types.ts | 32 +++++++ .../plugin-inspect/client.snapshot.d.ts | 1 + .../tsnapi/devframe/node.snapshot.d.ts | 7 ++ .../tsnapi/devframe/node.snapshot.js | 1 + 14 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 plugins/inspect/src/rpc/functions/list-instances.ts create mode 100644 plugins/inspect/src/spa/components/InstancesSmart.vue create mode 100644 plugins/inspect/src/spa/components/InstancesView.stories.ts create mode 100644 plugins/inspect/src/spa/components/InstancesView.vue diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index dcdf97ed..a511f40c 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -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' diff --git a/plugins/inspect/README.md b/plugins/inspect/README.md index 0942baf8..fb69c308 100644 --- a/plugins/inspect/README.md +++ b/plugins/inspect/README.md @@ -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 @@ -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. diff --git a/plugins/inspect/src/client/index.ts b/plugins/inspect/src/client/index.ts index 4dd578a1..7496d252 100644 --- a/plugins/inspect/src/client/index.ts +++ b/plugins/inspect/src/client/index.ts @@ -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 diff --git a/plugins/inspect/src/rpc/functions/list-instances.ts b/plugins/inspect/src/rpc/functions/list-instances.ts new file mode 100644 index 00000000..d277e65c --- /dev/null +++ b/plugins/inspect/src/rpc/functions/list-instances.ts @@ -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 => { + 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, + })) + }, + }), +}) diff --git a/plugins/inspect/src/rpc/index.ts b/plugins/inspect/src/rpc/index.ts index 0ebe8d0e..a8ac948b 100644 --- a/plugins/inspect/src/rpc/index.ts +++ b/plugins/inspect/src/rpc/index.ts @@ -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' @@ -21,6 +22,7 @@ export const serverFunctions = [ readAgentResource, listCommands, executeCommand, + listInstances, ] as const declare module 'devframe' { diff --git a/plugins/inspect/src/spa/App.vue b/plugins/inspect/src/spa/App.vue index e14ff1db..5f9c8751 100644 --- a/plugins/inspect/src/spa/App.vue +++ b/plugins/inspect/src/spa/App.vue @@ -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('functions') const { refresh, loading } = useRefresh() @@ -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. @@ -106,6 +113,7 @@ function reload(): void { + diff --git a/plugins/inspect/src/spa/components/InstancesSmart.vue b/plugins/inspect/src/spa/components/InstancesSmart.vue new file mode 100644 index 00000000..36dd6182 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesSmart.vue @@ -0,0 +1,23 @@ + + + diff --git a/plugins/inspect/src/spa/components/InstancesView.stories.ts b/plugins/inspect/src/spa/components/InstancesView.stories.ts new file mode 100644 index 00000000..3451a0b7 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesView.stories.ts @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import InstancesView from './InstancesView.vue' + +const meta = { + title: 'Inspector/InstancesView', + component: InstancesView, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const now = Date.now() + +export const Default: Story = { + args: { + instances: [ + { + id: 'devframes_plugin_inspect', + name: 'Devframe Inspector', + port: 9012, + origin: 'http://127.0.0.1:9012', + basePath: '/', + url: 'http://127.0.0.1:9012/', + pid: 4821, + rootDir: '/home/dev/projects/acme/web', + startedAt: now - 42_000, + hasMcp: true, + isCurrent: true, + }, + { + id: 'devframes_plugin_git', + name: 'Git', + port: 9010, + origin: 'http://127.0.0.1:9010', + basePath: '/__git/', + url: 'http://127.0.0.1:9010/__git/', + pid: 4790, + rootDir: '/home/dev/projects/acme/web', + startedAt: now - 3_930_000, + hasMcp: false, + isCurrent: false, + }, + { + id: 'devframes_plugin_terminals', + port: 9011, + origin: 'http://127.0.0.1:9011', + basePath: '/__terminals/', + url: 'http://127.0.0.1:9011/__terminals/', + pid: 4802, + rootDir: '/home/dev/projects/other/api', + startedAt: now - 91_000_000, + hasMcp: true, + isCurrent: false, + }, + ], + }, +} + +export const Loading: Story = { + args: { + instances: null, + }, +} + +export const Empty: Story = { + args: { + instances: [], + }, +} diff --git a/plugins/inspect/src/spa/components/InstancesView.vue b/plugins/inspect/src/spa/components/InstancesView.vue new file mode 100644 index 00000000..806dadd3 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesView.vue @@ -0,0 +1,87 @@ + + + diff --git a/plugins/inspect/src/spa/style.css b/plugins/inspect/src/spa/style.css index a1effaab..3f9f2fd2 100644 --- a/plugins/inspect/src/spa/style.css +++ b/plugins/inspect/src/spa/style.css @@ -645,3 +645,59 @@ textarea.args:focus { padding: 14px; font-style: italic; } + +/* ---- instances view ---- */ +.inst-url { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + margin-top: 8px; + font-family: var(--df-mono); + font-size: 12px; + color: var(--df-accent); + text-decoration: none; +} + +.inst-url:hover { + text-decoration: underline; +} + +.inst-url-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inst-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 12px; + margin: 12px 0 0; +} + +.inst-meta > div { + min-width: 0; +} + +.inst-meta-wide { + grid-column: 1 / -1; +} + +.inst-meta dt { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--df-fg-faint); + font-weight: 600; +} + +.inst-meta dd { + margin: 2px 0 0; + font-family: var(--df-mono); + font-size: 12px; + color: var(--df-fg-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/plugins/inspect/src/types.ts b/plugins/inspect/src/types.ts index 8eefb7e1..6be8d368 100644 --- a/plugins/inspect/src/types.ts +++ b/plugins/inspect/src/types.ts @@ -73,6 +73,38 @@ export interface DevframeInspectCommandInfo { children?: DevframeInspectCommandInfo[] } +/** + * Serializable projection of a single running devframe instance discovered + * in the machine-wide instance registry (`~/.devframe/instances/`), returned + * by `devframes:plugin:inspect:list-instances`. A live, node-only view — the + * inspector's Instances tab renders these as a read-only directory of the + * other devframes running alongside this one. + */ +export interface DevframeInspectInstanceInfo { + /** Definition id of the running instance. */ + id: string + /** Definition display name, when the instance declares one. */ + name?: string + /** Listening port. */ + port: number + /** Dialable HTTP origin, e.g. `http://127.0.0.1:9876`. */ + origin: string + /** Base path the devframe is mounted at (trailing slash). */ + basePath: string + /** Full SPA URL (`origin` + `basePath`) — the link the tab opens. */ + url: string + /** Process id of the instance's dev server. */ + pid: number + /** Working directory the instance was started from. */ + rootDir: string + /** Epoch-ms timestamp of registration (used to compute uptime). */ + startedAt: number + /** Whether the instance exposes an MCP endpoint. */ + hasMcp: boolean + /** Whether this is the inspector's own instance (matched by pid). */ + isCurrent: boolean +} + /** * Result envelope for `devframes:plugin:inspect:invoke`. Errors are * normalized to a serializable shape rather than thrown so the inspector diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts index 71f1a77e..bd340ba0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts @@ -9,6 +9,7 @@ export declare function connectInspect(_?: DevframeRpcClientOptions): Promise(_: D export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; +export declare function listLiveDevframeInstances(_?: { + instancesDir?: string; + timeoutMs?: number; +}): Promise<{ + live: DevframeInstanceRecord[]; + pruned: DevframeInstanceRecord[]; +}>; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { instancesDir?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index f323fdf0..d95147c9 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -16,6 +16,7 @@ export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } +export { listLiveDevframeInstances } export { normalizeHttpServerUrl } export { registerDevframeInstance } export { startHttpAndWs } From bc583c9df42c2db0fc268e907f9fc0c66704d6e4 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 4 Aug 2026 04:58:00 +0000 Subject: [PATCH 2/2] feat(inspect): explain the Instances tab and register example hub hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the Instances tab a self-describing empty state — what it lists, where discovery comes from (`~/.devframe/instances/`), and how to make instances appear (start another dev server, or a host that calls `registerDevframeInstance`; note the disable env var) — plus a one-line toolbar hint. Register the in-process example hub hosts in the instance registry so they show up in the tab like any standalone devframe: the vite-devframe-hub (which the tab previously left empty) and the a11y-messages-playground, mirroring the next-devframe-hub that already self-registers. Registration waits for the dev server to listen, reuses its own origin, and is folded into teardown so restarts don't leave ghost records. READMEs updated to keep the hub examples at parity. --- examples/a11y-messages-playground/README.md | 2 +- .../src/a11y-messages-playground.ts | 41 ++++++++++++++++- examples/next-devframe-hub/README.md | 4 +- examples/vite-devframe-hub/README.md | 4 +- .../src/vite-devframe-hub.ts | 45 ++++++++++++++++++- .../src/spa/components/InstancesView.vue | 20 ++++++++- plugins/inspect/src/spa/style.css | 36 +++++++++++++++ 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md index c95b80db..6053a95e 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -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 | diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts index 35474674..5d832137 100644 --- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts +++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts @@ -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' @@ -37,6 +39,7 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined let started: { close: () => Promise } | undefined + let registration: DevframeInstanceRegistration | undefined return { name: 'a11y-messages-playground', @@ -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] }) @@ -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(() => {}) }) diff --git a/examples/next-devframe-hub/README.md b/examples/next-devframe-hub/README.md index aa19d6e6..bd69987c 100644 --- a/examples/next-devframe-hub/README.md +++ b/examples/next-devframe-hub/README.md @@ -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 @@ -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 `/__/` | | `src/client/app/page.tsx` | The browser UI that consumes the hub protocol | diff --git a/examples/vite-devframe-hub/README.md b/examples/vite-devframe-hub/README.md index 440ac802..6b8c1fc1 100644 --- a/examples/vite-devframe-hub/README.md +++ b/examples/vite-devframe-hub/README.md @@ -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 @@ -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 | diff --git a/examples/vite-devframe-hub/src/vite-devframe-hub.ts b/examples/vite-devframe-hub/src/vite-devframe-hub.ts index 530d08be..5c1e8750 100644 --- a/examples/vite-devframe-hub/src/vite-devframe-hub.ts +++ b/examples/vite-devframe-hub/src/vite-devframe-hub.ts @@ -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' @@ -74,6 +76,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined let started: { close: () => Promise } | undefined + let registration: DevframeInstanceRegistration | undefined return { name: 'vite-devframe-hub', @@ -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 @@ -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 `__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(() => {}) }) diff --git a/plugins/inspect/src/spa/components/InstancesView.vue b/plugins/inspect/src/spa/components/InstancesView.vue index 806dadd3..819f4cf9 100644 --- a/plugins/inspect/src/spa/components/InstancesView.vue +++ b/plugins/inspect/src/spa/components/InstancesView.vue @@ -34,13 +34,29 @@ function formatUptime(startedAt: number): string {
{{ (instances ?? []).length }} running {{ (instances ?? []).length === 1 ? 'instance' : 'instances' }} + Devframe dev servers running on this machine, discovered via the shared registry (~/.devframe/instances/).
Discovering instances…
-
- No running devframe instances discovered. Each dev server registers itself while it runs — start one to see it here. +
+ +

+ No devframe instances discovered +

+

+ This tab lists every devframe dev server running on your machine, so you + can jump between them. Each server registers itself in + ~/.devframe/instances/ while it runs. +

+

+ Nothing shows up when only static/build servers are running, when + discovery is turned off (DEVFRAME_DISABLE_INSTANCE_REGISTRY=1), + or when an in-process host hasn't opted in. Start another + devframe dev server — or a hub host that calls + registerDevframeInstance() — then hit refresh. +

diff --git a/plugins/inspect/src/spa/style.css b/plugins/inspect/src/spa/style.css index 3f9f2fd2..b5609a9d 100644 --- a/plugins/inspect/src/spa/style.css +++ b/plugins/inspect/src/spa/style.css @@ -701,3 +701,39 @@ textarea.args:focus { text-overflow: ellipsis; white-space: nowrap; } + +.inst-empty { + max-width: 560px; + margin: 0 auto; + padding: 40px 20px; + text-align: center; + color: var(--df-fg-dim); +} + +.inst-empty p { + margin: 8px 0 0; + font-size: 12.5px; + line-height: 1.55; +} + +.inst-empty-icon { + font-size: 32px; + color: var(--df-fg-faint); +} + +.inst-empty-title { + margin-top: 10px !important; + font-size: 14px; + font-weight: 600; + color: var(--df-fg); +} + +.inst-empty code, +.toolbar code { + font-family: var(--df-mono); + font-size: 0.92em; + padding: 1px 5px; + border-radius: 4px; + background: var(--df-bg-active); + color: var(--df-fg-dim); +}