Skip to content

Commit 38b8448

Browse files
committed
feat(hub): skip capabilities.build:false devframes in static builds
A devframe whose value is inherently live (terminals over PTY sessions, the code-server process proxy, the assets manager) declares capabilities.build: false; buildHub now honors it the way createBuild does: the devframe is never mounted, so no dock, no SPA copy, no frame in __index.json, and none of its RPCs join the static dump. DF8007 logs each skip so a missing dock stays traceable. The terminals and code-server plugins declare the capability (assets already did).
1 parent 4e6a192 commit 38b8448

10 files changed

Lines changed: 108 additions & 8 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ await buildHub({
113113
})
114114
```
115115

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, 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.
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 stays out of the build entirely - no dock, no SPA copy, no RPCs in the dump (`DF8007` logs each skip). 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.
117119

118120
## Bring your own context
119121

‎docs/content/6.errors/DF8007.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
title: 'DF8007: Devframe Skipped by the Static Hub Build'
3+
description: 'Devframe "{name}" ({id}) declares `capabilities.build: false`, so the static hub build skips it.'
4+
---
5+
6+
## Message
7+
8+
> Devframe "`{name}`" (`{id}`) declares `capabilities.build: false`, so the static hub build skips it.
9+
10+
## Cause
11+
12+
A devframe whose value is inherently live - a terminal over PTY sessions, a proxy in front of a running process - declares `capabilities.build: false` on its definition. `buildHub` honors the declaration by leaving the devframe out of the static output entirely: its SPA is not copied, its dock is not registered (so it never appears in the baked `devframe:docks` shared state or `__index.json`), and its `setup(ctx)` never runs, so none of its RPCs join the static dump. This log line records each skip so a missing dock in a static build is traceable.
13+
14+
The built-in terminals, code-server, and assets devframes declare this capability.
15+
16+
## Example
17+
18+
```ts
19+
await buildHub({
20+
outDir: 'dist/__devframes',
21+
devframes: [
22+
createA11yDevframe(), // baked
23+
createTerminalsDevframe(), // `capabilities.build: false` -> DF8007, skipped
24+
],
25+
})
26+
```
27+
28+
## Fix
29+
30+
Nothing to fix - this is the declared behavior. If the tool does have a meaningful baked form (its panels read from `static`/`snapshot` RPCs), drop `capabilities.build: false` from the definition so the static build includes it.
31+
32+
## Source
33+
34+
- [`packages/hub/src/node/install-devframe.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/install-devframe.ts): `prepareDevframe()` logs this and skips the mount for a `capabilities.build: false` definition in a `mode: 'build'` hub context.

‎examples/hub-vite-minimal/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ pnpm --filter hub-vite-minimal build # vite build + the baked hub -> dist/
2626
pnpm --filter hub-vite-minimal preview # serve dist/ statically
2727
```
2828

29-
`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 same 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`), while live-server tools (terminals, code-server) render their panels without a backing process.
29+
`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.

‎packages/devframe/src/types/devframe.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,12 @@ export interface DevframeDefinition {
389389
* act on a `false` before doing any work:
390390
*
391391
* - `build: false`: `createCac` skips registering the `build` subcommand,
392-
* and `createBuild` refuses (throws `DF0042`) unless `{ force: true }`.
393-
* Useful for a devframe whose value is inherently live (e.g. it manages
394-
* real files on disk), so a static export would only ever produce a
395-
* broken, write-less shell of the tool.
392+
* `createBuild` refuses (throws `DF0042`) unless `{ force: true }`, and
393+
* a hub static build (`buildHub`) skips the devframe entirely - no dock,
394+
* no SPA copy, no RPCs in the dump (`DF8007` logs the skip). Useful for
395+
* a devframe whose value is inherently live (e.g. it manages real files
396+
* on disk), so a static export would only ever produce a broken,
397+
* write-less shell of the tool.
396398
* - `dev: false`: `createDevServer` refuses (throws `DF0058`) unless
397399
* `{ force: true }`. Useful for a devframe that only makes sense as a
398400
* static export (e.g. a report generator with nothing to serve live).

‎packages/hub/src/node/__tests__/build.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,35 @@ describe('buildHub', () => {
8282
expect(readFileSync(join(outDir, '__client-imports.js'), 'utf-8')).toContain('/__hub/alpha/__page-script/inject.js')
8383
})
8484

85+
it('skips a devframe declaring capabilities.build: false', async () => {
86+
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
87+
const live: DevframeDefinition = {
88+
...makeFrame('live', { distDir: makeDist('<h1>live</h1>') }),
89+
capabilities: { build: false },
90+
}
91+
92+
await buildHub({
93+
outDir,
94+
base: '/__hub/',
95+
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
96+
devframes: [makeFrame('alpha', { distDir: makeDist('<h1>alpha</h1>') }), live],
97+
})
98+
99+
// Nothing of the skipped devframe lands in the output: no SPA, no frame
100+
// entry, no RPCs in the dump, no dock in the baked shared state.
101+
expect(existsSync(join(outDir, 'live'))).toBe(false)
102+
const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8'))
103+
expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['alpha'])
104+
const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8'))
105+
expect(manifest['live:probe']).toBeUndefined()
106+
const stateEntry = manifest['devframe:rpc:server-state:get']
107+
const records = (Object.values(stateEntry.records) as string[])
108+
.map(path => readFileSync(join(outDir, path), 'utf-8'))
109+
const docksRecord = records.find(text => text.includes(HUB_EVENTS.sharedState.docks))
110+
expect(docksRecord).toContain('alpha')
111+
expect(docksRecord).not.toContain('Frame live')
112+
})
113+
85114
it('rejects a mount base outside the hub base', async () => {
86115
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
87116
await expect(buildHub({

‎packages/hub/src/node/assemble.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { resolve } from 'pathe'
88
import { joinURL, withTrailingSlash } from 'ufo'
99
import { resolveClientModuleSpecifier } from '../client-modules'
1010
import { diagnostics } from './diagnostics'
11-
import { prepareDevframe } from './install-devframe'
11+
import { prepareDevframe, skippedInStaticBuild } from './install-devframe'
1212

1313
/** Reserved filenames directly under the hub base; a frame id can't shadow them. */
1414
const RESERVED_HUB_PATHS = [
@@ -128,7 +128,10 @@ export async function mountDevframes(
128128
const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) })
129129
if (run)
130130
setups.push(run)
131-
frames.push({ id: def.id, base: frameBase, title: def.name })
131+
// A devframe skipped by the static build serves nothing, so it never
132+
// joins the `__index.json` frame list either.
133+
if (!skippedInStaticBuild(ctx, def))
134+
frames.push({ id: def.id, base: frameBase, title: def.name })
132135
}
133136
return setups
134137
}

‎packages/hub/src/node/diagnostics.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const diagnostics = defineDiagnostics({
3838
why: (p: { urlBase: string, base: string }) => `A static hub build can only write mounts under its own base: "${p.urlBase}" escapes "${p.base}".`,
3939
fix: 'buildHub maps each mount base to a directory under its `outDir`, so every mount must live under the hub base. Drop the `basePath` override (or the `ctx.install` base) that points outside it, or move the hub `base` up so it contains the mount.',
4040
},
41+
DF8007: {
42+
why: (p: { id: string, name: string }) => `Devframe "${p.name}" (${p.id}) declares \`capabilities.build: false\`, so the static hub build skips it.`,
43+
fix: 'This is the declared behavior for a tool whose value is inherently live (a terminal, a process proxy): its dock, SPA, and RPCs stay out of the static output. Drop `capabilities.build: false` from the definition if the tool does have a meaningful baked form.',
44+
},
4145
DF8100: {
4246
why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,
4347
fix: 'Use the `force` parameter to overwrite an existing registration.',

‎packages/hub/src/node/install-devframe.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,25 @@ async function serveDevframeAssets(
109109
ctx.views.hostStatic(base, typeof clientAssets === 'string' ? resolve(clientAssets) : clientAssets, d.importMetaUrl)
110110
}
111111

112+
/**
113+
* Whether a devframe stays out of a static hub build: `capabilities.build:
114+
* false` declares its value inherently live (a terminal, a process proxy),
115+
* so `buildHub` never mounts it, registers its dock, or bakes its RPCs.
116+
*/
117+
export function skippedInStaticBuild(ctx: DevframeHubContext, d: DevframeDefinition): boolean {
118+
return ctx.mode === 'build' && d.capabilities?.build === false
119+
}
120+
112121
export async function prepareDevframe(
113122
ctx: DevframeHubContext,
114123
d: DevframeDefinition,
115124
options: InstallDevframeOptions = {},
116125
): Promise<(() => Promise<void>) | null> {
126+
if (skippedInStaticBuild(ctx, d)) {
127+
diagnostics.DF8007({ id: d.id, name: d.name }, { method: 'log' })
128+
return null
129+
}
130+
117131
const strategy = d.duplicationStrategy ?? 'warn'
118132
const isDuplicate = ctx.docks.views.has(d.id)
119133

‎plugins/code-server/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export function createCodeServerDevframe(options: CodeServerOptions = {}): Devfr
4949
homepage: pkg.homepage,
5050
description: pkg.description,
5151
icon: 'ph:code-duotone',
52+
/**
53+
* The whole surface proxies a live `code-server` process, so a static
54+
* build would only produce a dead shell of the tool: `createBuild`
55+
* refuses and a hub static build skips this dock.
56+
*/
57+
capabilities: { build: false },
5258
/**
5359
* Leave undefined so `resolveBasePath` picks `/` standalone and
5460
* `/__<id>/` when hosted. Authors override via `options.basePath`.

‎plugins/terminals/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ export function createTerminalsDevframe(options: TerminalsOptions = {}): Devfram
5959
homepage: pkg.homepage,
6060
description: pkg.description,
6161
icon: 'ph:terminal-window-duotone',
62+
/**
63+
* Terminals are inherently live (PTY sessions over the streaming
64+
* channel), so a static build would only produce a dead shell of the
65+
* tool: `createBuild` refuses and a hub static build skips this dock.
66+
*/
67+
capabilities: { build: false },
6268
/**
6369
* Leave undefined so `resolveBasePath` picks `/` standalone and
6470
* `/__<id>/` when hosted. Authors override via `options.basePath`.

0 commit comments

Comments
 (0)