Serve a Hub Anywhere

initHub() from @devframes/hub/initiate serves a whole multi-devframe devtools install from one web-standard handler on a catch-all route.

initHub() from @devframes/hub/initiate serves a whole multi-devframe devtools install from one web-standard handler on a catch-all route.

import { createUi } from '@devframes/hub-ui'
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
import { createInspectDevframe } from '@devframes/plugin-inspect'
import { createTerminalsDevframe } from '@devframes/plugin-terminals'

export const hub = initHub({
  base: DEVFRAMES_HUB_BASE, // required: the conventional `/__devframes/`
  devframes: [createInspectDevframe(), createTerminalsDevframe()],
  ui: createUi(),
  configure(ctx) {
    ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' })
  },
})

base is required (echoed as hub.base); each mounted devframe runs setup() against the shared hub context. The instance mirrors initDevframe's API; see The Standard Handler.

The shared socket

One transport serves the namespace, chosen in precedence: ws.port pins a side-car; server shares the host framework's node:http upgrade at <base>__ws; ws: { sidecar: true } takes a free port; none leaves the socket to the host framework: Node uses hub.attach(server), Bun/Deno attachBunWsTransport / attachDenoWsTransport.

The advertised path is hub-base-absolute (/__devframes/__ws). Dev-reevaluated host frameworks (Next, Nitro) memoize it on globalThis.

The namespace

The namespace serves the hub UI at / (the ui.viewer SPA, or an index document when headless) and each devframe's SPA at <id>/ with its own __connection.json pointing at the shared socket. Hub-level endpoints sit alongside: embedded.js (the ui.embedded bootstrap), __connection.json, __ws, __index.json, __client-imports.js, and __mcp (mounted by the 'auto' default once agent tools exist). The route table is in the Hub API reference.

Devframe ids become URL segments, validated: reserved names throw DF8000, non-route-safe DF8004.

The ui slot

The hub is headless; DevframeHubUi is pure data:

interface DevframeHubUi {
  viewer?: { distDir: string } // a standalone SPA served at the namespace root
  embedded?: { entry: string } // a prebuilt bootstrap served at <base>embedded.js
  assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
  setup?: (ctx) => void | Promise<void> // publish static config via ctx.staticConfig
}

@devframes/hub-ui's createUi() is the reference (standalone viewer SPA + floating dock); its setup(ctx) publishes config to ctx.staticConfig.ui (ConnectionMeta.configs.ui):

  • viewer: set to false to disable the standalone viewer.
  • branding: rebrand the UI (logo, name, primary color). background accepts any CSS background value (color, gradient, image, or transparent) or { light, dark } variants. These flat forms apply everywhere. Use { standalone, iframe? } to specialize the framed viewer; an omitted iframe value falls back to standalone.
  • dockPreferences tunes the dock rail: categoryOrder, floating-dock maxVisibleItems, first-run defaultMode ('float'/'edge') and defaultPosition.
  • embeddedVisibility sets the floating dock's reveal policy:

    • 'normal' (default): shows immediately.
    • 'passive': hidden until Shift+Alt+D, then persisted per-origin (later browser sessions start shown).
    • 'hidden': hidden until Shift+Alt+D, that browser session only.

Renderer modules

A dock type's renderer (e.g. JSON-Render) composes via initHub({ renderers }). Each registration { type, file, importName? } (file = a prebuilt ES module exporting a DockRenderer) is served at <base>__renderers/<type>.mjs and published into the devframe:dock-renderers manifest; client runtimes import it lazily on first mount:

import { createUi } from '@devframes/hub-ui'
import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub'

initHub({
  ui: createUi(),
  renderers: [jsonRenderUiRenderer()],
})

A renderer registered at boot (createDevframeClientRuntime({ renderers })) overrides the manifest; an uncovered type shows the hub UI provider's missing-renderer fallback.

Registrations are validated fail-fast: one module per type (DF8108), an existing bundle (DF8109), a route-safe type name (DF8110).

One Auth for the hub

The hub's single Auth is one gate at the shared transport for every mounted devframe, built-ins, and the MCP route; one handshake (OTP, magic link, or pre-shared token) unlocks the namespace; auth: false disables it for localhost.

The aggregate MCP route mounts through the 'auto' default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; mcp: true forces it on, mcp: false off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and mcp: { authorization } adds an identity check when the hub is reachable beyond loopback. A mounted devframe's own mcp setting is ignored: the hub exposes one aggregate route over them all, and warns (DF8005) when a devframe asks for MCP while the hub set mcp: false.

Singular vs hub mounting

A devframe's SPA and RPC client are byte-identical in both cases; only the environment differs:

What the SPA / RPC client seesSingular (/__git/)Hub (/__devframes/git/)
Runtime base/__git//__devframes/git/ (transparent)
__connection.jsonown meta, own socketper-devframe meta → shared hub socket
RPC registrythis devframe's functionsmerged: all mounted devframes + hub built-ins, cross-devframe
Shared stateown context's slotsall mounted devframes' slots + hub slots
Authown gate, own tokenthe single hub Auth
Hub subsystemsnonedocks, terminals, messages, commands; the devframe is also an iframe dock
MCP<base>__mcp, this devframe's toolsthe hub-level aggregate
Isolationhard (own context, own transport)cooperative (shared context)

Static builds

buildHub() from @devframes/hub/build is the hub counterpart of the build adapter: it bakes the whole hub into a directory any static file server can serve. Each devframe's SPA is copied to <outDir>/<id>/ (absolute-path page scripts alongside at <id>/__page-script/), the UI slot's viewer and embedded.js next to them, and __connection.json (backend: 'static') plus a shared RPC dump at the hub base, with a snapshot of every shared-state key (docks, commands, renderer manifest) baked in - so createDevframeClientRuntime() and every panel boot from the dump with no live server.

import { buildHub } from '@devframes/hub/build'

await buildHub({
  outDir: 'dist/__devframes', // corresponds to `base` at serve time
  devframes: [createA11yDevframe(), createMessagesDevframe()],
  ui: createUi(),
})

A mount served outside the hub base writes to outDir's parent (the deploy root) by its absolute path, so a host can keep the hub at /__devtools/ while its devframe SPAs and assets stay top-level siblings (/__inspect/, /__devtools-assets/) rather than children of the hub base. outDir holds the hub subtree, its parent holds the whole deploy root, and either directory serves as-is.

Browser-side tools keep working in full: a page script still loads into the host page and talks to its panel over the in-page channel (the a11y inspector scans a production app exactly as it does in dev). Reads resolve from the baked dump (static/snapshot RPCs, shared-state snapshots); live writes (messages, command execution) have no server, so the browser clients degrade to local no-ops, and a panel's dock-activation deep links ride a same-origin BroadcastChannel instead of the RPC relay.

A devframe whose value is inherently live declares capabilities.build: false and silently stays out of the build entirely - no dock, no SPA copy, no RPCs in the dump. The built-in terminals, code-server, and assets devframes declare it, so a hub mounting every built-in bakes only the tools that mean something statically. See the buildHub options reference, and examples/a11y-messages-playground for a Vite host whose vite build output ships the hub.

Bring your own context

Host frameworks that assemble createHubContext + ctx.install themselves pass the context instead of a devframes list:

const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx })

It then serves only hub-level endpoints and transport; serve each mounted devframe's meta from hub.connectionMeta() yourself.

The same context option works for a static build: buildHub({ context: ctx, outDir }) bakes an already-mounted context instead of a devframes list, reading ctx.frames and ctx.views.buildStaticDirs for what to emit, so a host that mounted its own context reuses buildHub rather than reimplementing it. Pass clean: false to bake beside an app's own build output.