diff --git a/alias.ts b/alias.ts
index 3e8986693..8a5893640 100644
--- a/alias.ts
+++ b/alias.ts
@@ -48,6 +48,7 @@ export const alias = {
'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'),
'devframe/initiate': r('devframe/src/adapters/initiate.ts'),
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
+ '@devframes/hub/build': r('hub/src/node/build.ts'),
'@devframes/hub/client': r('hub/src/client/index.ts'),
'@devframes/hub/constants': r('hub/src/constants.ts'),
'@devframes/hub/initiate': r('hub/src/node/initiate.ts'),
diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md
index 06ba2debb..843e242b0 100644
--- a/docs/content/1.guide/18.hub-initiate.md
+++ b/docs/content/1.guide/18.hub-initiate.md
@@ -99,6 +99,24 @@ A devframe's SPA and RPC client are byte-identical in both cases; only the envir
| MCP | `__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](/adapters/build): it bakes the whole hub into a directory any static file server can serve. Each devframe's SPA is copied to `//` (absolute-path page scripts alongside at `/__page-script/`), the UI slot's viewer and `embedded.js` next to them, and `__connection.json` (`backend: 'static'`) plus a shared [RPC dump](/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(),
+})
+```
+
+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](/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](/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:
diff --git a/docs/content/2.adapters/4.build.md b/docs/content/2.adapters/4.build.md
index dd7def6e1..84e6e7365 100644
--- a/docs/content/2.adapters/4.build.md
+++ b/docs/content/2.adapters/4.build.md
@@ -28,3 +28,5 @@ await createBuild(myDevframe, {
| `pretty` | `false` | Pretty-print dump JSON. |
The RPC client runs read-only. For a custom URL base, build with relative asset paths (`vite.base: './'`).
+
+`buildHub()` from `@devframes/hub/build` produces the same kind of deploy for a whole hub: [Static builds](/guide/hub-initiate#static-builds).
diff --git a/docs/content/3.frameworks/1.vite.md b/docs/content/3.frameworks/1.vite.md
index ad9a564e7..4f770e5a8 100644
--- a/docs/content/3.frameworks/1.vite.md
+++ b/docs/content/3.frameworks/1.vite.md
@@ -63,3 +63,5 @@ export default defineConfig({
```
Pass `ui` to swap the hub UI provider, `ui: false` for headless (via `@devframes/vite/hub/client`'s `mountDevframeHubClient()`). Vite DevTools (`@vitejs/devtools-kit`) supports this natively; recommended once (`{ quiet: true }` to silence).
+
+`build: true` also bakes the hub into `vite build` output: [`buildHub`](/guide/hub-initiate#static-builds) writes the static hub subtree into `` and the UI's `embedded.js` tag is injected into the built HTML, so the deployed app ships working devtools against a `static` backend (baked reads, no live server).
diff --git a/docs/content/6.errors/DF8005.md b/docs/content/6.errors/DF8005.md
index 31685d678..67067d357 100644
--- a/docs/content/6.errors/DF8005.md
+++ b/docs/content/6.errors/DF8005.md
@@ -30,4 +30,4 @@ initHub({
## Source
-- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts): `initHub()` emits this while mounting each devframe when the hub turned MCP off but the devframe requests one.
+- [`packages/hub/src/node/assemble.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/assemble.ts): `mountDevframes()` emits this while mounting each devframe when the hub turned MCP off but the devframe requests one.
diff --git a/docs/content/6.errors/DF8006.md b/docs/content/6.errors/DF8006.md
new file mode 100644
index 000000000..2b964be16
--- /dev/null
+++ b/docs/content/6.errors/DF8006.md
@@ -0,0 +1,33 @@
+---
+title: 'DF8006: Static Build Mount Escapes the Hub Base'
+description: 'A static hub build can only write mounts under its own base: "{urlBase}" escapes "{base}".'
+---
+
+## Message
+
+> A static hub build can only write mounts under its own base: "`{urlBase}`" escapes "`{base}`"
+
+## Cause
+
+`buildHub` maps every mounted URL base to a directory under its `outDir` (which corresponds to the hub `base` at serve time), so a mount whose base lies outside the hub base has no on-disk location in the output. This happens when a devframe is installed with an explicit base outside the hub base, e.g. `ctx.install(devframe, { base: '/elsewhere/' })` from `configure`.
+
+## Example
+
+```ts
+await buildHub({
+ outDir: 'dist/__devframes',
+ async configure(ctx) {
+ // ✗ Bad: `/tools/x/` is not under the `/__devframes/` hub base
+ await ctx.install(myDevframe, { base: '/tools/x/' })
+ },
+})
+```
+
+## Fix
+
+- Drop the `base` override so the devframe mounts at `/`, or point it somewhere under the hub base.
+- Or move the hub `base` up (e.g. `base: '/'`) so it contains every mount.
+
+## Source
+
+- [`packages/hub/src/node/build.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/build.ts): `buildHub()`'s mount-to-disk mapping throws this for any mount base outside the hub base.
diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md
index 17da5c9e3..96fb817f8 100644
--- a/docs/content/8.references/3.events.md
+++ b/docs/content/8.references/3.events.md
@@ -66,6 +66,14 @@ A hub-aware RPC client reads or subscribes via `rpc.client.register(...)`; the [
| `devframe:user-settings` | shared state | Persisted project-scope hub settings (`DevframeDocksUserSettings`). |
| `devframe:terminals` | streaming channel | Live terminal output stream, keyed by session id. |
+### Same-origin `BroadcastChannel`s
+
+Used on a `static` backend, where no live server can relay a client's request to its sibling browsing contexts.
+
+| Name | Posted by | Carries |
+|---|---|---|
+| `devframe:docks:activate` | a panel iframe (e.g. the messages panel's activate actions) | The `{ dockId, params? }` activation; the client runtime in the host page switches the dock locally. |
+
## Core devframe events
This map covers notifications only; request/response RPC endpoints (`devframe:rpc:server-state:*`, `devframe:streaming:subscribe`, `anonymous:devframe:auth`, …) are typed in `types/rpc-augments.ts`, not events.
diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md
index 418676578..b5b3cfb33 100644
--- a/docs/content/8.references/6.hub-api.md
+++ b/docs/content/8.references/6.hub-api.md
@@ -84,6 +84,16 @@ What `initHub()` serves under its `base`: [The namespace](/guide/hub-initiate#th
| `__client-imports.js` | dock client-script import map for hub UI providers |
| `__mcp` | aggregate MCP endpoint over the tool registry (`mcp: 'auto'` default: mounted once agent tools exist) |
+## `buildHub` options
+
+The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/hub-initiate#static-builds). `devframes`, `services`, `rpcDeclarations`, `configure`, `ui`, `renderers`, `name`, `version`, `cwd`, and `getStorageDir` carry the same contracts as their `initHub` counterparts.
+
+| Option | Purpose |
+|---|---|
+| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). |
+| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
+| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |
+
## Client runtime options
The options of `createDevframeClientRuntime()`: [The client runtime](/guide/client-context#the-client-runtime).
diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md
index acab15ef5..054aab92f 100644
--- a/examples/a11y-messages-playground/README.md
+++ b/examples/a11y-messages-playground/README.md
@@ -21,6 +21,22 @@ The `dev` script builds the workspace first (the a11y page-script bundle and bot
devframe SPAs must exist), then starts Vite bound to `0.0.0.0`. Open the printed
URL.
+## Production build
+
+```sh
+pnpm --filter a11y-messages-playground build # vite build + buildHub -> dist/
+pnpm --filter a11y-messages-playground preview # serve dist/ statically
+```
+
+`vite build` bakes the whole hub into `dist/__hub/` via `buildHub()` from
+`@devframes/hub/build`: both devframe SPAs, the a11y page-script bundle, a
+`backend: 'static'` connection meta, and the RPC dump (shared-state snapshots,
+the a11y config, the baked messages feed). Served from any static file server,
+the production page boots the client runtime against the static backend - the
+a11y inspector scans the built app over the in-page channel exactly as in dev,
+and the baked message's **Open a11y inspector** action still switches docks
+(riding a same-origin `BroadcastChannel` instead of the RPC relay).
+
## What you'll see
The window is split in two:
diff --git a/examples/a11y-messages-playground/package.json b/examples/a11y-messages-playground/package.json
index ef1366587..9a36c1435 100644
--- a/examples/a11y-messages-playground/package.json
+++ b/examples/a11y-messages-playground/package.json
@@ -8,6 +8,7 @@
"scripts": {
"dev": "pnpm -C ../.. run build && vite --host",
"build": "vite build",
+ "preview": "vite preview --host",
"typecheck": "tsc --noEmit"
},
"dependencies": {
diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
index ae427ad79..8a0e3492c 100644
--- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts
+++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
@@ -1,10 +1,11 @@
import type { HubInstance } from '@devframes/hub/initiate'
-import type { DevframeDefinition } from 'devframe'
+import type { DevframeDefinition, DevframeStorageScope } from 'devframe'
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { Server as NodeHttpServer } from 'node:http'
import { homedir } from 'node:os'
+import { buildHub } from '@devframes/hub/build'
import { initHub } from '@devframes/hub/initiate'
-import { join } from 'pathe'
+import { join, resolve } from 'pathe'
export interface A11yMessagesPlaygroundOptions {
/** Mount base the hub answers under. Default: `/__hub/`. */
@@ -28,9 +29,16 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
let viteConfig: ResolvedConfig | undefined
let hub: HubInstance | undefined
+ const storageDirs = (cwd: string) => (scope: DevframeStorageScope): string => {
+ if (scope === 'workspace')
+ return join(cwd, '.devframe')
+ if (scope === 'project')
+ return join(cwd, 'node_modules/.a11y-messages-playground')
+ return join(homedir(), '.a11y-messages-playground')
+ }
+
return {
name: 'a11y-messages-playground',
- apply: 'serve',
configResolved(config) {
viteConfig = config
@@ -55,13 +63,7 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
auth: false,
...(options.port == null && httpServer ? { server: httpServer } : {}),
...(ws ? { ws } : {}),
- getStorageDir(scope) {
- if (scope === 'workspace')
- return join(cwd, '.devframe')
- if (scope === 'project')
- return join(cwd, 'node_modules/.a11y-messages-playground')
- return join(homedir(), '.a11y-messages-playground')
- },
+ getStorageDir: storageDirs(cwd),
devframes: options.devframes ?? [],
/**
* List the playground alongside standalone devframes in discovery
@@ -84,6 +86,38 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
async closeBundle() {
await hub?.close().catch(() => {})
hub = undefined
+
+ // Production build: bake the whole hub statically into the app's dist
+ // (``), so the built page works from any static file
+ // server - the a11y page script still loads, its in-page channel still
+ // scans, and the panels boot from the baked RPC dump.
+ if (viteConfig?.command !== 'build')
+ return
+ const cwd = viteConfig.root
+ await buildHub({
+ base,
+ cwd,
+ outDir: join(resolve(cwd, viteConfig.build.outDir), base.slice(1)),
+ getStorageDir: storageDirs(cwd),
+ devframes: options.devframes ?? [],
+ async configure(ctx) {
+ // Bake one demo entry into the static feed snapshot; its activate
+ // action exercises the message → dock navigation, which rides a
+ // same-origin BroadcastChannel on the static backend.
+ await ctx.messages.add({
+ message: 'Static hub build',
+ description: 'This feed is a build-time snapshot; live entries need the dev server.',
+ level: 'info',
+ category: 'hub',
+ actions: [{
+ id: 'open-a11y',
+ label: 'Open a11y inspector',
+ kind: 'activate',
+ activate: { dockId: 'devframes_plugin_a11y' },
+ }],
+ })
+ },
+ })
},
}
}
diff --git a/examples/hub-vite-minimal/README.md b/examples/hub-vite-minimal/README.md
index 4419e1aa2..001475d95 100644
--- a/examples/hub-vite-minimal/README.md
+++ b/examples/hub-vite-minimal/README.md
@@ -18,3 +18,12 @@ Open the printed URL - the host page carries the floating dock via one injected
- `transformIndexHtml` injects `