From d555625295561ac77a42d4435ff050c8b3f37d08 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 04:16:20 +0000 Subject: [PATCH 1/5] fix: copy Next static exports with fs.promises.cp The sync variant's native directory copy fails (EACCES) on shared-mount filesystems (virtiofs, Docker Desktop mounts), breaking the workspace build there. --- examples/next-runtime-snapshot/scripts/build-spa.mjs | 8 ++++++-- plugins/git/scripts/build-spa.mjs | 9 ++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/next-runtime-snapshot/scripts/build-spa.mjs b/examples/next-runtime-snapshot/scripts/build-spa.mjs index 4b3d477e1..22baefe89 100644 --- a/examples/next-runtime-snapshot/scripts/build-spa.mjs +++ b/examples/next-runtime-snapshot/scripts/build-spa.mjs @@ -1,9 +1,13 @@ -import { chmodSync, cpSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { chmodSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { cp } from 'node:fs/promises' import { join } from 'node:path' rmSync('dist/client', { recursive: true, force: true }) mkdirSync('dist', { recursive: true }) -cpSync('src/client/out', 'dist/client', { recursive: true }) +// `fs.promises.cp` rather than `cpSync`: the sync variant's native directory +// copy fails (EACCES) on shared-mount filesystems (e.g. virtiofs, Docker +// Desktop mounts). +await cp('src/client/out', 'dist/client', { recursive: true }) // A built SPA must be readable to be served. Some filesystems (e.g. Docker // Desktop's shared mounts) drop the read bits when copying, which yields diff --git a/plugins/git/scripts/build-spa.mjs b/plugins/git/scripts/build-spa.mjs index 2e5f439c8..65aa957a5 100644 --- a/plugins/git/scripts/build-spa.mjs +++ b/plugins/git/scripts/build-spa.mjs @@ -1,6 +1,9 @@ -import { cpSync, rmSync } from 'node:fs' +import { cp, rm } from 'node:fs/promises' // The Next.js static export is the plugin's iframe SPA; it ships in the // lockstep `@devframes/plugin-git--assets` package, not the node package. -rmSync('assets-pkg/dist', { recursive: true, force: true }) -cpSync('src/client/out', 'assets-pkg/dist', { recursive: true }) +// `fs.promises.cp` rather than `cpSync`: the sync variant's native directory +// copy fails (EACCES) on shared-mount filesystems (e.g. virtiofs, Docker +// Desktop mounts). +await rm('assets-pkg/dist', { recursive: true, force: true }) +await cp('src/client/out', 'assets-pkg/dist', { recursive: true }) From 4e6a1920f188eaaad88cb395c7b362b4f5197c40 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 04:16:44 +0000 Subject: [PATCH 2/5] feat(hub): static hub builds with buildHub Bake a whole hub into a static deploy, the multi-devframe counterpart of devframe's createBuild: buildHub() from @devframes/hub/build copies each devframe's SPA and page script, writes a static connection meta at the hub and every frame base, and bakes the shared RPC dump (static/snapshot RPCs plus every shared-state key), so the client runtime and every panel boot from any static file server with no live server. - devframe core: shared writeStaticRpcDump (reused by createBuild), an undefined fallback for un-baked shared-state keys, and per-frame metas can re-point relative resolution at the hub's own meta via a served baseUrl. - static client degradation: createMessagesClient keeps handles local on a static backend, and dock activation (messages panel deep links) rides a same-origin BroadcastChannel instead of the RPC relay. - @devframes/vite/hub: opt-in build option bakes the hub into vite build output and injects embedded.js into the built HTML. - examples: a11y-messages-playground and hub-vite-minimal ship production builds (build + preview), covered by a static-hub e2e suite (a11y scanning the production app over the in-page channel, baked messages feed, cross-dock activation). --- alias.ts | 1 + docs/content/1.guide/18.hub-initiate.md | 16 ++ docs/content/2.adapters/4.build.md | 2 + docs/content/3.frameworks/1.vite.md | 2 + docs/content/6.errors/DF8005.md | 2 +- docs/content/6.errors/DF8006.md | 33 +++ docs/content/8.references/3.events.md | 8 + docs/content/8.references/6.hub-api.md | 10 + examples/a11y-messages-playground/README.md | 16 ++ .../a11y-messages-playground/package.json | 1 + .../src/a11y-messages-playground.ts | 54 +++- examples/hub-vite-minimal/README.md | 9 + examples/hub-vite-minimal/package.json | 2 + examples/hub-vite-minimal/vite.config.ts | 11 +- knip.jsonc | 2 +- packages/devframe/src/adapters/build.ts | 38 +-- packages/devframe/src/client/connection.ts | 10 +- .../devframe/src/node/rpc-shared-state.ts | 5 +- packages/devframe/src/rpc/dump/index.ts | 1 + packages/devframe/src/rpc/dump/write.ts | 49 ++++ packages/devframe/src/types/context.ts | 19 +- packages/hub/package.json | 1 + packages/hub/src/client/host.ts | 9 + packages/hub/src/client/messages.ts | 54 ++++ packages/hub/src/events.ts | 9 + packages/hub/src/node/__tests__/build.test.ts | 96 +++++++ packages/hub/src/node/assemble.ts | 134 +++++++++ packages/hub/src/node/build.ts | 261 ++++++++++++++++++ packages/hub/src/node/diagnostics.ts | 4 + packages/hub/src/node/initiate.ts | 131 +-------- packages/hub/tsdown.config.ts | 1 + packages/vite/src/hub.ts | 54 +++- playwright.config.ts | 9 + plugins/messages/src/client/App.vue | 13 +- .../tsnapi/@devframes/hub/build.snapshot.d.ts | 24 ++ .../tsnapi/@devframes/hub/build.snapshot.js | 6 + .../@devframes/hub/constants.snapshot.d.ts | 3 + .../tsnapi/@devframes/vite/hub.snapshot.d.ts | 1 + .../tsnapi/devframe/rpc/dump.snapshot.d.ts | 4 + .../tsnapi/devframe/rpc/dump.snapshot.js | 1 + tests/e2e/a11y-messages-hub-static.spec.ts | 50 ++++ tsconfig.base.json | 3 + 42 files changed, 970 insertions(+), 189 deletions(-) create mode 100644 docs/content/6.errors/DF8006.md create mode 100644 packages/devframe/src/rpc/dump/write.ts create mode 100644 packages/hub/src/node/__tests__/build.test.ts create mode 100644 packages/hub/src/node/assemble.ts create mode 100644 packages/hub/src/node/build.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.js create mode 100644 tests/e2e/a11y-messages-hub-static.spec.ts diff --git a/alias.ts b/alias.ts index 08db230d3..9936bde0a 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 6e69a564a..6cc7d2041 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -99,6 +99,22 @@ 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, terminals, 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. 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 aba07f1ac..5bd2d5f3e 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 8e7845a54..ed456da37 100644 --- a/docs/content/6.errors/DF8005.md +++ b/docs/content/6.errors/DF8005.md @@ -29,4 +29,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 has no MCP 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 has no MCP 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 035826dea..de67dc133 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 (opt-in `mcp`) | +## `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 064767deb..0ebdc2053 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..1682f29b2 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 `