Skip to content

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.

Hub screenshot
Orchestrating multiple devtools (from A Playground)

What the hub adds ​

DevframeHubContext adds four subsystems to DevframeNodeContext:

SubsystemSurfacePurpose
ctx.docksregister / update / values / activateDock entries (iframes, launchers, custom-render) and groups; activate(dockId, params?) sets the active dock (Cross-iframe dock activation).
ctx.terminalsregister / startChildProcessAggregate terminal sessions, streaming output (Terminals).
ctx.messagesadd / update / remove / clearServer-side toast/notification queue (FIFO, capped at 1000).
ctx.commandsregister / execute / listHierarchical 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:

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

ts
// 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:

FieldPurpose
commandBound command id; out-of-process viewers dispatch via hub:commands:execute (register a handler via ctx.commands).
terminalSessionIdTracked session id; a "view in terminal" action calls hub:docks:activate with the terminals dock id and { sessionId }.
digestLatest progress line, shown inline; patch via docks.update().

onLaunch lets a same-process host invoke directly; provide command, onLaunch, or both.

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

ts
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):

ts
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:

ts
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:

js
// next.config.mjs
export default { skipTrailingSlashRedirect: true }

Duplicate devframes ​

When a devframe shares an already-mounted id, duplicationStrategy decides:

StrategyBehavior
'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, …).
ts
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.

ts
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:

CategoryWeightTypical use
framework-100Framework internals.
default0Uncategorized.
app100App tools.
ui150Components, styling.
data250State, storage, queries.
web300Network, platform, a11y.
performance350Profiling, metrics.
advanced400Power-user tools.
docs500Documentation.
~builtin1000Built-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:

ChannelTypeWhat it carries
devframe:docks shared stateDevframeDockEntry[]Every registered dock entry.
devframe:commands shared stateDevframeServerCommandEntry[]Serializable command list (handlers stripped).
devframe:user-settings shared stateDevframeDocksUserSettingsPersisted per-workspace hub settings.
devframe:docks:active shared stateDevframeDocksActiveStateMost recent dock activation request.
hub:commands:execute RPC(id, ...args) => unknownServer-side command dispatch.
hub:docks:activate RPC({ dockId, params? }) => voidSwitch 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:

Diagnostics ​

Hub-side diagnostic codes live in the DF8xxx range — see the error reference.

Released under the MIT License.