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.
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 tofalseto disable the standalone viewer.branding: rebrand the UI (logo, name, primary color).backgroundaccepts any CSSbackgroundvalue (color, gradient, image, ortransparent) or{ light, dark }variants. These flat forms apply everywhere. Use{ standalone, iframe? }to specialize the framed viewer; an omittediframevalue falls back tostandalone.dockPreferencestunes the dock rail:categoryOrder, floating-dockmaxVisibleItems, first-rundefaultMode('float'/'edge') anddefaultPosition.embeddedVisibilitysets the floating dock's reveal policy:'normal'(default): shows immediately.'passive': hidden untilShift+Alt+D, then persisted per-origin (later browser sessions start shown).'hidden': hidden untilShift+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 sees | Singular (/__git/) | Hub (/__devframes/git/) |
|---|---|---|
| Runtime base | /__git/ | /__devframes/git/ (transparent) |
__connection.json | own meta, own socket | per-devframe meta → shared hub socket |
| RPC registry | this devframe's functions | merged: all mounted devframes + hub built-ins, cross-devframe |
| Shared state | own context's slots | all mounted devframes' slots + hub slots |
| Auth | own gate, own token | the single hub Auth |
| Hub subsystems | none | docks, terminals, messages, commands; the devframe is also an iframe dock |
| MCP | <base>__mcp, this devframe's tools | the hub-level aggregate |
| Isolation | hard (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.
Client Scripts & Client Context
A dock client script runs a devframe's code inside the host page; the client context is how client scripts reach the hub.
Cross-Devframe Services
ctx.services lets one devframe expose a typed, namespaced capability visible to every devframe. Two tiers: in-process services (provide/get) share live objects between devframes; wire services also register RPC and advertise to RPC clients.