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 `