Skip to content

Commit f38e886

Browse files
authored
feat(hub): static hub builds with buildHub (#339)
1 parent c2bd9c8 commit f38e886

47 files changed

Lines changed: 1051 additions & 195 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const alias = {
4848
'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'),
4949
'devframe/initiate': r('devframe/src/adapters/initiate.ts'),
5050
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
51+
'@devframes/hub/build': r('hub/src/node/build.ts'),
5152
'@devframes/hub/client': r('hub/src/client/index.ts'),
5253
'@devframes/hub/constants': r('hub/src/constants.ts'),
5354
'@devframes/hub/initiate': r('hub/src/node/initiate.ts'),

docs/content/1.guide/18.hub-initiate.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ A devframe's SPA and RPC client are byte-identical in both cases; only the envir
9999
| MCP | `<base>__mcp`, this devframe's tools | the hub-level aggregate |
100100
| Isolation | hard (own context, own transport) | cooperative (shared context) |
101101

102+
## Static builds
103+
104+
`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 `<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](/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.
105+
106+
```ts
107+
import { buildHub } from '@devframes/hub/build'
108+
109+
await buildHub({
110+
outDir: 'dist/__devframes', // corresponds to `base` at serve time
111+
devframes: [createA11yDevframe(), createMessagesDevframe()],
112+
ui: createUi(),
113+
})
114+
```
115+
116+
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.
117+
118+
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.
119+
102120
## Bring your own context
103121

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

docs/content/2.adapters/4.build.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ await createBuild(myDevframe, {
2828
| `pretty` | `false` | Pretty-print dump JSON. |
2929

3030
The RPC client runs read-only. For a custom URL base, build with relative asset paths (`vite.base: './'`).
31+
32+
`buildHub()` from `@devframes/hub/build` produces the same kind of deploy for a whole hub: [Static builds](/guide/hub-initiate#static-builds).

docs/content/3.frameworks/1.vite.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ export default defineConfig({
6363
```
6464

6565
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).
66+
67+
`build: true` also bakes the hub into `vite build` output: [`buildHub`](/guide/hub-initiate#static-builds) writes the static hub subtree into `<outDir><base>` 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).

docs/content/6.errors/DF8005.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ initHub({
3030

3131
## Source
3232

33-
- [`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.
33+
- [`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.

docs/content/6.errors/DF8006.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: 'DF8006: Static Build Mount Escapes the Hub Base'
3+
description: 'A static hub build can only write mounts under its own base: "{urlBase}" escapes "{base}".'
4+
---
5+
6+
## Message
7+
8+
> A static hub build can only write mounts under its own base: "`{urlBase}`" escapes "`{base}`"
9+
10+
## Cause
11+
12+
`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`.
13+
14+
## Example
15+
16+
```ts
17+
await buildHub({
18+
outDir: 'dist/__devframes',
19+
async configure(ctx) {
20+
// ✗ Bad: `/tools/x/` is not under the `/__devframes/` hub base
21+
await ctx.install(myDevframe, { base: '/tools/x/' })
22+
},
23+
})
24+
```
25+
26+
## Fix
27+
28+
- Drop the `base` override so the devframe mounts at `<hub base><id>/`, or point it somewhere under the hub base.
29+
- Or move the hub `base` up (e.g. `base: '/'`) so it contains every mount.
30+
31+
## Source
32+
33+
- [`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.

docs/content/8.references/3.events.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ A hub-aware RPC client reads or subscribes via `rpc.client.register(...)`; the [
6666
| `devframe:user-settings` | shared state | Persisted project-scope hub settings (`DevframeDocksUserSettings`). |
6767
| `devframe:terminals` | streaming channel | Live terminal output stream, keyed by session id. |
6868

69+
### Same-origin `BroadcastChannel`s
70+
71+
Used on a `static` backend, where no live server can relay a client's request to its sibling browsing contexts.
72+
73+
| Name | Posted by | Carries |
74+
|---|---|---|
75+
| `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. |
76+
6977
## Core devframe events
7078

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

docs/content/8.references/6.hub-api.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ What `initHub()` serves under its `base`: [The namespace](/guide/hub-initiate#th
8484
| `__client-imports.js` | dock client-script import map for hub UI providers |
8585
| `__mcp` | aggregate MCP endpoint over the tool registry (`mcp: 'auto'` default: mounted once agent tools exist) |
8686

87+
## `buildHub` options
88+
89+
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.
90+
91+
| Option | Purpose |
92+
|---|---|
93+
| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). |
94+
| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
95+
| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |
96+
8797
## Client runtime options
8898

8999
The options of `createDevframeClientRuntime()`: [The client runtime](/guide/client-context#the-client-runtime).

examples/a11y-messages-playground/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ The `dev` script builds the workspace first (the a11y page-script bundle and bot
2121
devframe SPAs must exist), then starts Vite bound to `0.0.0.0`. Open the printed
2222
URL.
2323

24+
## Production build
25+
26+
```sh
27+
pnpm --filter a11y-messages-playground build # vite build + buildHub -> dist/
28+
pnpm --filter a11y-messages-playground preview # serve dist/ statically
29+
```
30+
31+
`vite build` bakes the whole hub into `dist/__hub/` via `buildHub()` from
32+
`@devframes/hub/build`: both devframe SPAs, the a11y page-script bundle, a
33+
`backend: 'static'` connection meta, and the RPC dump (shared-state snapshots,
34+
the a11y config, the baked messages feed). Served from any static file server,
35+
the production page boots the client runtime against the static backend - the
36+
a11y inspector scans the built app over the in-page channel exactly as in dev,
37+
and the baked message's **Open a11y inspector** action still switches docks
38+
(riding a same-origin `BroadcastChannel` instead of the RPC relay).
39+
2440
## What you'll see
2541

2642
The window is split in two:

examples/a11y-messages-playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"scripts": {
99
"dev": "pnpm -C ../.. run build && vite --host",
1010
"build": "vite build",
11+
"preview": "vite preview --host",
1112
"typecheck": "tsc --noEmit"
1213
},
1314
"dependencies": {

0 commit comments

Comments
 (0)