Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
18 changes: 18 additions & 0 deletions docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,24 @@ A devframe's SPA and RPC client are byte-identical in both cases; only the envir
| 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](/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.

```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:
Expand Down
2 changes: 2 additions & 0 deletions docs/content/2.adapters/4.build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 2 additions & 0 deletions docs/content/3.frameworks/1.vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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).
2 changes: 1 addition & 1 deletion docs/content/6.errors/DF8005.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 33 additions & 0 deletions docs/content/6.errors/DF8006.md
Original file line number Diff line number Diff line change
@@ -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 `<hub base><id>/`, 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.
8 changes: 8 additions & 0 deletions docs/content/8.references/3.events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions examples/a11y-messages-playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions examples/a11y-messages-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"dev": "pnpm -C ../.. run build && vite --host",
"build": "vite build",
"preview": "vite preview --host",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
54 changes: 44 additions & 10 deletions examples/a11y-messages-playground/src/a11y-messages-playground.ts
Original file line number Diff line number Diff line change
@@ -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/`. */
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
// (`<outDir><base>`), 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' },
}],
})
},
})
},
}
}
Expand Down
9 changes: 9 additions & 0 deletions examples/hub-vite-minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@ Open the printed URL - the host page carries the floating dock via one injected
- `transformIndexHtml` injects `<script type="module" src="/__devframes/embedded.js">` into the host page, so the floating dock mounts itself.

The same `initHub` instance mounts identically on Nitro, Hono, Next.js, and Rsbuild - see the sibling `hub-*-minimal` examples.

## Production build

```sh
pnpm --filter hub-vite-minimal build # vite build + the baked hub -> dist/
pnpm --filter hub-vite-minimal preview # serve dist/ statically
```

`build: true` on `viteDevframeHub` bakes the hub into `dist/__devframes/` and injects the `embedded.js` tag into the built HTML, so the production bundle ships the docks against a `static` backend: the floating dock and standalone viewer boot from the baked RPC dump (each panel's snapshot reads, e.g. the Open Graph report of `defaultUrl`). Inherently-live tools (terminals, code-server, assets) declare `capabilities.build: false` and stay out of the static output entirely.
2 changes: 2 additions & 0 deletions examples/hub-vite-minimal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"homepage": "https://github.com/devframes/devframe/tree/main/examples/hub-vite-minimal",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
11 changes: 10 additions & 1 deletion examples/hub-vite-minimal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ const builtinDevframes = [
createDataInspectorDevframe({ id: 'devframes_plugin_data-inspector' }),
createA11yDevframe(),
createMessagesDevframe(),
createOgDevframe(),
// `defaultUrl` doubles as the snapshot the static build bakes for the OG
// panel (its dump fetches the page at build time).
createOgDevframe({ defaultUrl: 'https://vite.dev' }),
createAssetsDevframe({ watch: false }),
]

Expand Down Expand Up @@ -69,6 +71,13 @@ export default defineConfig({
plugins: [
viteDevframeHub({
quiet: true,
/**
* Bake the hub into `vite build` output too (`dist/__devframes/`), so
* the production bundle ships the same docks against a `static`
* backend: baked reads (each panel's snapshot RPCs), no live server.
* Preview with `pnpm build && pnpm preview`.
*/
build: true,
devframes: builtinDevframes,
/**
* Rebrand the reference UI to Vite's own purple in one field, no CSS:
Expand Down
2 changes: 1 addition & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
]
},
"packages/hub": {
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/{index,initiate}.ts"]
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/{index,initiate,build}.ts"]
},
"packages/hub-ui": {
// `playground/client-scripts/*.ts` are dock `action` entries the
Expand Down
38 changes: 3 additions & 35 deletions packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,17 @@ import fs from 'node:fs/promises'
import process from 'node:process'
import { colors as c } from 'devframe/utils/colors'
import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets'
import { structuredCloneStringify } from 'devframe/utils/structured-clone'
import { dirname, resolve } from 'pathe'
import { resolve } from 'pathe'
import { resolveClientAssets } from '../client-assets'
import {
DEVFRAME_CONNECTION_META_FILENAME,
DEVFRAME_RPC_DUMP_DIRNAME,
DEVFRAME_RPC_DUMP_MANIFEST_FILENAME,
} from '../constants'
import { createHostContext } from '../node/context'
import { diagnostics } from '../node/diagnostics'
import { createH3DevframeHost } from '../node/host-h3'
import { collectStaticRpcDump } from '../rpc/dump/static'
import { strictJsonStringify } from '../rpc/serialization'
import { writeStaticRpcDump } from '../rpc/dump/write'

export interface CreateBuildOptions {
/** Output directory. Defaults to `dist-static`. */
Expand Down Expand Up @@ -95,18 +93,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
// registered definitions, since the service itself defines none.
applySnapshotRpc(ctx, d.rpc?.snapshot)

await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })

await writeConnectionMeta(ctx, outDir)

console.log(c.cyan`[devframe] writing RPC dump to ${resolve(outDir, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME)}`)
const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx)
await writeDumpFiles(dump, outDir, options.pretty ? 2 : undefined)
await fs.writeFile(
resolve(outDir, DEVFRAME_RPC_DUMP_MANIFEST_FILENAME),
JSON.stringify(dump.manifest, null, 2),
'utf-8',
)
await writeStaticRpcDump(dump, outDir, { pretty: options.pretty })

console.log(c.green`[devframe] built "${d.id}" -> ${outDir}`)
}
Expand Down Expand Up @@ -136,29 +127,6 @@ async function writeConnectionMeta(ctx: DevframeNodeContext, outDir: string): Pr
)
}

/** Encode and write each sharded RPC dump file under `outDir`. */
async function writeDumpFiles(
dump: Awaited<ReturnType<typeof collectStaticRpcDump>>,
outDir: string,
indent: number | undefined,
): Promise<void> {
for (const [filepath, file] of Object.entries(dump.files)) {
const fullpath = resolve(outDir, filepath)
await fs.mkdir(dirname(fullpath), { recursive: true })
const text = file.serialization === 'structured-clone'
? structuredCloneStringify(file.data)
: strictJsonStringify(file.data, file.fnName)
await fs.writeFile(
fullpath,
// structured-clone-es output is single-line; only JSON honors `indent`.
file.serialization === 'json' && indent != null
? JSON.stringify(JSON.parse(text), null, indent)
: text,
'utf-8',
)
}
}

/**
* Attach a `dump` to each {@link DevframeRpcOptions.snapshot} target so the
* static collector bakes it, even though the (service-owned) definition
Expand Down
10 changes: 9 additions & 1 deletion packages/devframe/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,17 @@ export async function setupDevframeConnection(
throw new Error(`Failed to fetch connection meta from ${metaUrl}: ${response.status}`)

const connectionMeta = await response.json() as ConnectionMeta
const loadedFrom = response.url || metaUrl
const connection: DevframeConnection = {
connectionMeta,
metaBaseUrl: response.url || metaUrl,
/**
* A served `baseUrl` re-points relative resolution (RPC dump shards,
* transport paths) at the meta that owns them: a static hub build's
* per-frame meta directs each frame SPA at the hub's own dump.
*/
metaBaseUrl: connectionMeta.baseUrl
? new URL(connectionMeta.baseUrl, loadedFrom).href
: loadedFrom,
authToken: readStoredAuthToken(
options.authToken ?? connectionMeta.authToken,
),
Expand Down
5 changes: 4 additions & 1 deletion packages/devframe/src/node/rpc-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,13 @@ export function createRpcSharedStateServerHost(
},
/**
* Pre-compute snapshots for the build-mode static dump so the SPA
* can read them without a live server.
* can read them without a live server. The `undefined` fallback makes
* a key the build never created resolve like the live handler does
* (returning `undefined`) instead of failing the dump lookup.
*/
dump: () => ({
inputs: host.keys().map(key => [key] as [string]),
fallback: undefined,
}),
})

Expand Down
Loading
Loading