---
title: "Serve a Hub Anywhere"
description: "initHub() from @devframes/hub/initiate serves a whole multi-devframe devtools install from one web-standard handler on a catch-all route."
canonical_url: "https://devfra.me/guide/hub-initiate"
---
# 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.

```ts
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](https://devfra.me/adapters/initiate#mount-the-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](https://devfra.me/references/hub-api#hub-namespace-routes).

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:

```ts
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`. Built-in devframe panels read `branding.primaryColor` from the existing connection metadata at startup and map it to CSS primary tokens. Standalone SPAs keep their default accent; status and preview palettes remain independent. Reload panels after changing startup branding.
- **`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.
- **`locale`** sets the default UI language, one of the tags in `HUB_UI_LOCALES` (`en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `es`, `fr`, `de`, `pt-BR`, `ru`). Without it the UI follows the browser language and falls back to English. A visitor's own pick in Settings → Appearance wins and persists per browser. The hub UI writes the language it resolved back into `ConnectionMeta.configs.ui.locale` on its window, so a same-origin frame that inherits the connection (see [Client](https://devfra.me/guide/client)) reads the effective language at boot.

To add a language, add its tag and native name to `packages/hub-ui/src/locales.ts`, translate a copy of `packages/hub-ui/src/client/i18n/locales/en.json` under that tag, and register the file in the `messages` map in `packages/hub-ui/src/client/i18n/index.ts`. The i18n test fails on a missing key or a changed `{slot}`.

## Onboarding without the hub installed

A host that wants DevTools as an opt-in install ships `@devframes/hub-ui-onboard` instead of the hub: a 20 kB floating button at the same `<base>embedded.js` URL, an Install action that runs the project's package manager, and an `onInstalled` hook that hands `base` to the real hub in the same process. See [Opt-in DevTools with Onboarding](https://devfra.me/guide/hub-ui-onboard).

## Renderer modules

A dock type's renderer (e.g. [JSON-Render](https://devfra.me/guide/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:

```ts
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. `mcp: { exposeSharedState }` takes `false` or a `(key) => boolean` filter to choose which shared-state keys agents can read through `devframe_state_read` and `devframe://state/<key>`. `mcp: { instructions }` sets the usage guidance that the route sends in the MCP `initialize` result. `mcp: { resourceTemplates }` adds resource templates that coding agents list through `resources/templates/list` and read through `resources/read`, and `mcp: { onResourcesChanged }` sends `notifications/resources/list_changed` each time its `notify()` runs. `mcp: { prompts }` adds prompt templates that coding agents list through `prompts/list` and build through `prompts/get`. The hub exposes one aggregate route over every mounted devframe's tools.

## 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](https://devfra.me/adapters/build): 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](https://devfra.me/adapters/build) 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.

```ts
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](https://devfra.me/guide/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](https://devfra.me/references/hub-api#buildhub-options) reference, and [`examples/a11y-messages-playground`](https://github.com/devframes/devframe/tree/main/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:

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


## Sitemap

See the full [sitemap](https://devfra.me/sitemap.md) for all pages.
