Skip to content

Commit 44f7054

Browse files
committed
feat(hub): bake mounts outside the hub base as deploy-root siblings
Relax buildHub's resolveOutPath so a mount base outside the hub base resolves to outDir's parent (the deploy root) by its absolute path, with the hub subtree still at outDir. This lets buildHub bake a context whose devframe SPAs and assets are served as top-level siblings of the hub base (Vite DevTools' layout) rather than children of it. Closes #353
1 parent b7fdf7f commit 44f7054

6 files changed

Lines changed: 53 additions & 21 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ await buildHub({
113113
})
114114
```
115115

116+
A mount served outside the hub base writes to `outDir`'s parent (the deploy root) by its absolute path, so a host can keep the hub at `/__devtools/` while its devframe SPAs and assets stay top-level siblings (`/__inspect/`, `/__devtools-assets/`) rather than children of the hub base. `outDir` holds the hub subtree, its parent holds the whole deploy root, and either directory serves as-is.
117+
116118
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.
117119

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

docs/content/6.errors/DF8006.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,38 @@
11
---
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}".'
2+
title: 'DF8006: Static Build Mount Base Is Not Absolute'
3+
description: 'A static hub build writes each mount either under its base ("{base}") or as an absolute-path sibling of it, but "{urlBase}" is neither.'
44
---
55

66
## Message
77

8-
> A static hub build can only write mounts under its own base: "`{urlBase}`" escapes "`{base}`"
8+
> A static hub build writes each mount either under its base ("`{base}`") or as an absolute-path sibling of it, but "`{urlBase}`" is neither
99
1010
## Cause
1111

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`.
12+
`buildHub` maps a mount under the hub `base` into its `outDir` (the hub subtree), and any other mount to the deploy root (`outDir`'s parent) by its absolute path, so a devframe SPA or asset dir served as a sibling of the hub base still lands beside it. A mount base that is neither under the hub base nor an absolute path has no on-disk location in the output.
1313

1414
## Example
1515

1616
```ts
1717
await buildHub({
1818
outDir: 'dist/__devframes',
1919
async configure(ctx) {
20-
// ✗ Bad: `/tools/x/` is not under the `/__devframes/` hub base
21-
await ctx.install(myDevframe, { base: '/tools/x/' })
20+
// ✓ Good: under the hub base, written into `outDir`
21+
await ctx.install(a, { base: '/__devframes/a/' })
22+
// ✓ Good: an absolute sibling, written to the deploy root beside the hub
23+
await ctx.install(b, { base: '/tools/b/' })
24+
// ✗ Bad: a relative base resolves against neither
25+
await ctx.install(c, { base: 'tools/c/' })
2226
},
2327
})
2428
```
2529

2630
## Fix
2731

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.
32+
Give the mount an absolute base (starting with `/`): a base under the hub base writes into `outDir`, and any other absolute base writes to the deploy root by its path.
3033

3134
## Source
3235

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.
36+
- [`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 a mount base that is not absolute.
37+
</content>
38+
</invoke>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/
9090

9191
| Option | Purpose |
9292
|---|---|
93-
| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). |
93+
| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). A mount served outside the hub base (a devframe SPA or asset dir kept as a sibling of it) is written to this directory's parent (the deploy root) by its absolute path. |
9494
| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
9595
| `context` | An already-mounted `DevframeHubContext` to bake instead of `devframes` (the build counterpart of `initHub({ context })`); reads `ctx.frames` and `ctx.views.buildStaticDirs`. Mutually exclusive with `devframes`. |
9696
| `clean` | Remove `outDir` before writing. Default `true`; set `false` to bake beside an app's own build output. |

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

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,27 @@ describe('buildHub', () => {
163163
expect(manifest['alpha:probe']).toMatchObject({ type: 'static' })
164164
})
165165

166-
it('rejects a mount base outside the hub base', async () => {
167-
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
168-
await expect(buildHub({
166+
it('bakes a mount outside the hub base as a deploy-root sibling', async () => {
167+
const deployRoot = mkdtempSync(join(tmpdir(), 'hub-build-out-'))
168+
const outDir = join(deployRoot, '__hub')
169+
170+
await buildHub({
169171
outDir,
170172
base: '/__hub/',
171173
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
172174
async configure(ctx) {
173-
await ctx.install(makeFrame('gamma', { distDir: makeDist('<h1>gamma</h1>') }), { base: '/elsewhere/' })
175+
await ctx.install(makeFrame('gamma', { distDir: makeDist('<h1>gamma</h1>') }), { base: '/gamma/' })
174176
},
175-
})).rejects.toThrow(/escapes "\/__hub\/"/)
177+
})
178+
179+
// The hub subtree lands at `outDir`, while the sibling frame resolves to
180+
// the deploy root (outDir's parent) by its absolute path, beside the hub.
181+
expect(existsSync(join(outDir, '__connection.json'))).toBe(true)
182+
expect(readFileSync(join(deployRoot, 'gamma/index.html'), 'utf-8')).toContain('gamma')
183+
// The sibling frame still gets its per-frame meta pointing back at the hub.
184+
const frameMeta = JSON.parse(readFileSync(join(deployRoot, 'gamma/__connection.json'), 'utf-8'))
185+
expect(frameMeta.baseUrl).toBe('/__hub/__connection.json')
186+
const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8'))
187+
expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['gamma'])
176188
})
177189
})

packages/hub/src/node/build.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export interface BuildHubOptions {
2323
* Output directory the hub subtree is written into. It corresponds to the
2424
* hub {@link BuildHubOptions.base} at serve time: building with
2525
* `base: '/__devframes/'` into `dist/__devframes` makes the deployed app's
26-
* `dist/` servable as-is by any static file server.
26+
* `dist/` servable as-is by any static file server. A mount served outside
27+
* the hub base (a devframe SPA or asset dir kept as a sibling of it) is
28+
* written to this directory's parent (the deploy root) by its absolute path,
29+
* so `dist/` still serves the whole layout.
2730
*/
2831
outDir: string
2932
/**
@@ -123,11 +126,21 @@ export async function buildHub(options: BuildHubOptions): Promise<void> {
123126
await fs.rm(outDir, { recursive: true })
124127
await fs.mkdir(outDir, { recursive: true })
125128

126-
/** Map a hub-base-relative URL base to its on-disk location under `outDir`. */
129+
/**
130+
* Map a served URL base to its on-disk location. A base under the hub
131+
* {@link base} writes into the hub subtree at `outDir` (so `outDir`
132+
* corresponds to the hub base). A base outside it is a deploy-root sibling:
133+
* `outDir`'s parent is the deploy root, and the base resolves under it by its
134+
* absolute path. This is the layout Vite DevTools serves, where devframe SPAs
135+
* and assets sit beside `/__devtools/` rather than under it.
136+
*/
137+
const deployRoot = dirname(outDir)
127138
const resolveOutPath = (urlBase: string): string => {
128-
if (!urlBase.startsWith(base))
139+
if (urlBase.startsWith(base))
140+
return resolve(outDir, urlBase.slice(base.length))
141+
if (!urlBase.startsWith('/'))
129142
throw diagnostics.DF8006({ urlBase, base })
130-
return resolve(outDir, urlBase.slice(base.length))
143+
return resolve(deployRoot, urlBase.slice(1))
131144
}
132145

133146
await copyBuildStatics(ctx, resolveOutPath)

packages/hub/src/node/diagnostics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ export const diagnostics = defineDiagnostics({
3535
fix: 'A hub exposes one aggregate MCP endpoint over every mounted devframe, so per-devframe `mcp` settings are ignored. Drop `mcp: false` from `initHub` (the `\'auto\'` default mounts the aggregate route once agent tools exist) to surface this devframe\'s tools, or drop `mcp` from the devframe to silence this warning.',
3636
},
3737
DF8006: {
38-
why: (p: { urlBase: string, base: string }) => `A static hub build can only write mounts under its own base: "${p.urlBase}" escapes "${p.base}".`,
39-
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.',
38+
why: (p: { urlBase: string, base: string }) => `A static hub build writes each mount either under its base ("${p.base}") or as an absolute-path sibling of it, but "${p.urlBase}" is neither.`,
39+
fix: 'buildHub maps a mount under the hub base into its `outDir`, and any other mount to the deploy root (`outDir`\'s parent) by its absolute path. Give the mount an absolute base (starting with `/`) so it resolves to one of those.',
4040
},
4141
DF8100: {
4242
why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,

0 commit comments

Comments
 (0)