Hub
@devframes/hub orchestrates many devtools sharing a UI: a dock registry, terminal aggregation, message/toast queue, and command palette. It ships no UI — each framework kit provides its own atop the hub's RPC + shared-state protocol.

What the hub adds
DevframeHubContext adds four subsystems to DevframeNodeContext:
| Subsystem | Surface | Purpose |
|---|---|---|
ctx.docks | register / update / values / activate | Dock entries (iframes, launchers, custom-render) and groups; activate(dockId, params?) sets the active dock (Cross-iframe dock activation). |
ctx.terminals | register / startChildProcess | Aggregate terminal sessions, streaming output (Terminals). |
ctx.messages | add / update / remove / clear | Server-side toast/notification queue (FIFO, capped at 1000). |
ctx.commands | register / execute / list | Hierarchical command palette with keybindings and when clauses. |
Data-driven UI panels are an opt-in JSON-Render integration (a json-render dock type).
Built-in RPC
Every hub context auto-registers these client-callable functions:
hub:commands:execute— invoke a server command by id.hub:docks:activate— switch the active dock (Cross-iframe dock activation).hub:messages:add/update/remove/clear— write the messages feed.hub:terminals:write/resize— drive a PTY session by id.
Host-specific capabilities (open in editor, reveal in finder) ship as kit-registered functions.
Commands as agent tools
A server command with an agent field (agent surface) becomes a ctx.agent MCP tool:
ctx.commands.register({
id: 'app:build',
title: 'Run build',
agent: {
description: 'Run the production build. Call after config or dependency changes to verify the app still builds.',
args: [v.object({ configFile: v.optional(v.string()) })],
},
handler: (opts?: { configFile?: string }) => runBuild(opts),
})args takes positional Standard Schema schemas (a single v.object(...) unwraps into the input); omit for zero-arg. safety defaults to 'action'; when clauses are unenforced for agent calls.
Cross-iframe dock activation
A mounted devframe's iframe uses hub:docks:activate to switch the active dock.
// From inside a mounted devframe's iframe (its own RPC client):
await rpc.call('hub:docks:activate', {
dockId: 'devframes_plugin_terminals',
params: { sessionId }, // opaque bag the target dock interprets
})It mirrors into the devframe:docks:active shared-state slot; the terminals dock reads params.sessionId, unknown ids no-op (DF8107). Server-side: ctx.docks.activate(dockId, params?).
Process-control launchers
A type: 'launcher' dock entry is a one-click action tile. Three optional launcher fields make it a live process controller:
| Field | Purpose |
|---|---|
command | Bound command id; out-of-process viewers dispatch via hub:commands:execute (register a handler via ctx.commands). |
terminalSessionId | Tracked session id; a "view in terminal" action calls hub:docks:activate with the terminals dock id and { sessionId }. |
digest | Latest progress line, shown inline; patch via docks.update(). |
onLaunch lets a same-process host invoke directly; provide command, onLaunch, or both.
ctx.commands.register({ id: 'app:build', title: 'Run build', handler: runBuild })
const launcher = ctx.docks.register({
type: 'launcher',
id: 'app:build',
title: 'Build',
icon: 'ph:hammer-duotone',
launcher: { title: 'Run build', command: 'app:build', status: 'idle' },
})
async function runBuild() {
const session = await ctx.terminals.startChildProcess(
{ command: 'vite', args: ['build'] },
{ id: 'app:build-session', title: 'vite build' },
)
launcher.update({ launcher: { title: 'Run build', command: 'app:build', status: 'loading', terminalSessionId: session.id } })
// A child-process session keeps its `status` live: `running` → `stopped` on a
// clean exit, `error` on a non-zero exit or spawn failure. Map it onto the
// launcher and read the exit code from getResult().
const { exitCode } = await session.getResult()
launcher.update({ launcher: {
title: 'Run build',
command: 'app:build',
terminalSessionId: session.id,
status: exitCode === 0 ? 'success' : 'error',
error: exitCode === 0 ? undefined : `vite build exited ${exitCode}`,
} })
}Mounting a devframe into a hub
ctx.install(def) registers a DevframeDefinition as a dock and runs its setup(ctx) — the imperative counterpart to initHub's devframes list.
import { createHubContext } from '@devframes/hub/node'
const ctx = await createHubContext({ cwd, host, mode: 'dev' })
await ctx.install(myDevframe)Framework kits wrap this in a plugin shell (e.g. @vitejs/devtools-kit's createPluginFromDevframe).
Connecting embedded SPAs
A mounted SPA loads at /__<id>/ and calls connectDevframe(), which fetches ./__connection.json — served by the host's mountConnectionMeta(base):
const host: DevframeHost = {
mountStatic(base, distDir) { /* serve files */ },
mountConnectionMeta(base) {
// serve `${base}__connection.json` → { backend: 'websocket', websocket: port }
},
resolveOrigin() { /* … */ },
getStorageDir(scope) {
// workspace = committable, team-shared; project = per-checkout; global = per-user
if (scope === 'workspace')
return join(cwd, '.devframe')
if (scope === 'project')
return join(cwd, 'node_modules/.my-hub')
return join(homedir(), '.my-hub')
},
}Omitting mountConnectionMeta (with servable clientAssets) triggers DF8106 and falls back to same-origin inheritance.
Bundled hosts (Next.js)
Load node-side plugin packages via dynamic import() with webpackIgnore/turbopackIgnore comments:
const pkgs = ['@devframes/plugin-git', '@devframes/plugin-terminals']
const defs = await Promise.all(
pkgs.map(p => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ p)),
// Each package's default export is its `create<X>Devframe` factory, not a
// pre-built instance — call it to get one.
).then(mods => mods.map(m => m.default()))
for (const def of defs)
await ctx.install(def)SPAs serve at /__<id>/ with relative assets; set skipTrailingSlashRedirect:
// next.config.mjs
export default { skipTrailingSlashRedirect: true }Duplicate devframes
When a devframe shares an already-mounted id, duplicationStrategy decides:
| Strategy | Behavior |
|---|---|
'warn' (default) | Keep the first, drop the later, emit DF8105. |
'silent' | Drop the later one without warning. |
'throw' | Throw DF8105. |
'duplicate' | Every instance coexists under a disambiguated dock id (my-tool, my-tool-2, …). |
defineDevframe({
id: 'my-tool',
// …
duplicationStrategy: 'duplicate',
})Grouping dock entries
Related dock entries collapse under one dock-bar button (a type: 'group' entry); an entry whose groupId matches the group's id joins it.
ctx.docks.register({
type: 'group',
id: 'nuxt',
title: 'Nuxt',
icon: 'logos:nuxt-icon',
category: 'framework',
defaultChildId: 'nuxt:overview', // optional; popover-only when omitted
})
ctx.docks.register({
type: 'iframe',
id: 'nuxt:overview',
title: 'Overview',
icon: 'ph:gauge-duotone',
url: '/__nuxt-overview/',
groupId: 'nuxt', // joins the group above
})Group and members stay independent top-level entries in devframe:docks; defaultChildId opens on activation. Grouping affects the dock bar, not iframes — to share one soft-navigated iframe, give docks a shared frameId and mark the anchor with subTabs (Shared-iframe soft navigation).
The dual role of category
category (ordered by DEFAULT_CATEGORIES_ORDER, default 'default') sets an ungrouped entry's outer dock-bar bucket. A grouped entry takes its outer bucket from the group's category, and its own category becomes an in-group sub-category; a member whose groupId never resolves renders top-level under its own category.
Known categories
DEFAULT_CATEGORIES_ORDER (from @devframes/hub, /node, /client, /constants) names the default buckets:
| Category | Weight | Typical use |
|---|---|---|
framework | -100 | Framework internals. |
default | 0 | Uncategorized. |
app | 100 | App tools. |
ui | 150 | Components, styling. |
data | 250 | State, storage, queries. |
web | 300 | Network, platform, a11y. |
performance | 350 | Profiling, metrics. |
advanced | 400 | Power-user tools. |
docs | 500 | Documentation. |
~builtin | 1000 | Built-in views; always last. |
Kits can interleave category ids or override weights; an unknown category sorts as 0.
The protocol — what the UI sees
A hub-aware UI imports no hub classes; it reads these shared-state keys and RPC methods:
| Channel | Type | What it carries |
|---|---|---|
devframe:docks shared state | DevframeDockEntry[] | Every registered dock entry. |
devframe:commands shared state | DevframeServerCommandEntry[] | Serializable command list (handlers stripped). |
devframe:user-settings shared state | DevframeDocksUserSettings | Persisted per-workspace hub settings. |
devframe:docks:active shared state | DevframeDocksActiveState | Most recent dock activation request. |
hub:commands:execute RPC | (id, ...args) => unknown | Server-side command dispatch. |
hub:docks:activate RPC | ({ dockId, params? }) => void | Switch the active dock. |
Broadcast notifications (devframe:docks:activate, devframe:terminals:updated, devframe:messages:updated) arrive via rpc.client.register(...); the client host registers devframe:docks:activate for you (Events Reference).
Running plugin code in the host page
The hub ships a headless browser runtime, createDevframeClientHost() (@devframes/hub/client): booted in the host page, it assembles the client context and imports each dock entry's client script (Client Scripts & Client Context).
Example
Two minimal hubs mount every built-in plugin behind an icon dock, plus a "Tabbed Tool" demonstrating shared-iframe soft navigation:
examples/hub-vite/— a ~120-line Vite host with a vanilla DOM UI.examples/hub-next/— the same, from a Next.js App Router app.
Diagnostics
Hub-side diagnostic codes live in the DF8xxx range — see the error reference.