Skip to content

Commit fe5d46d

Browse files
committed
feat(hub): let buildHub bake statics/frames outside the hub base
Add a `deployBase` option to `buildHub` so a context whose devframe SPAs and static assets are served as siblings of the hub base (not children of it) still bakes. `outDir` maps to `deployBase`; the hub's own artifacts write under `base` within it, and every mount base resolves against the deploy root by its path below it. Default `deployBase` stays `base`, the existing single-subtree layout. Closes #353
1 parent b7fdf7f commit fe5d46d

7 files changed

Lines changed: 131 additions & 35 deletions

File tree

docs/content/6.errors/DF8006.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
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 Escapes the Deploy Root'
3+
description: 'A static hub build can only write mounts under its deploy root: "{urlBase}" escapes "{base}".'
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 can only write mounts under its deploy root: "`{urlBase}`" escapes "`{base}`"
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 every mounted URL base to a directory under its `outDir`, so a mount whose base lies outside the deploy root has no on-disk location in the output. The deploy root defaults to the hub `base`; a wider `deployBase` (e.g. `/`) lets devframe SPAs and assets live as siblings of the hub base. This fires when a devframe is installed with an explicit base outside that root, e.g. `ctx.install(devframe, { base: '/elsewhere/' })` from `configure`.
1313

1414
## Example
1515

@@ -26,8 +26,8 @@ await buildHub({
2626
## Fix
2727

2828
- 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.
29+
- Or pass a wider `deployBase` (e.g. `/`) so `outDir` maps to a deploy root that contains every mount, with the hub's own artifacts still written under `base`.
3030

3131
## Source
3232

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.
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 deploy root.

docs/content/6.errors/DF8007.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: 'DF8007: Hub Base Outside the Deploy Root'
3+
description: 'The hub base "{base}" is outside the deploy root "{deployBase}", so its artifacts have no place under `outDir`.'
4+
---
5+
6+
## Message
7+
8+
> The hub base "`{base}`" is outside the deploy root "`{deployBase}`", so its artifacts have no place under `outDir`
9+
10+
## Cause
11+
12+
`buildHub` writes the hub's own artifacts (`__connection.json`, `__index.json`, the RPC dump, renderer modules, the UI slot) under `base` inside `outDir`, and `outDir` maps to `deployBase`. If `base` does not live within `deployBase`, there is no directory below `outDir` for those artifacts.
13+
14+
## Example
15+
16+
```ts
17+
await buildHub({
18+
outDir: 'dist',
19+
base: '/__hub/',
20+
/** ✗ Bad: `/__hub/` is not under `/elsewhere/` */
21+
deployBase: '/elsewhere/',
22+
})
23+
```
24+
25+
## Fix
26+
27+
- Move `base` under `deployBase` (the hub base must be a child of the deploy root).
28+
- Or widen `deployBase` so it contains the hub base (e.g. `deployBase: '/'`).
29+
30+
## Source
31+
32+
- [`packages/hub/src/node/build.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/build.ts): `buildHub()` throws this when `base` does not start with the resolved `deployBase`.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ 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 the build writes into; maps to `deployBase` at serve time (build `base: '/__devframes/'` into `dist/__devframes`, or `deployBase: '/'` into `dist` for a sibling layout). |
9494
| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
95+
| `deployBase` | Deploy-root URL that `outDir` maps to, letting devframe SPAs and assets serve as siblings of the hub `base` rather than children. Must contain `base`. Default `base`. |
9596
| `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`. |
9697
| `clean` | Remove `outDir` before writing. Default `true`; set `false` to bake beside an app's own build output. |
9798
| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |

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

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

166+
it('bakes a sibling-layout context with deployBase mapping outDir to the deploy root', async () => {
167+
const outDir = mkdtempSync(join(tmpdir(), 'hub-deploy-out-'))
168+
const cwd = mkdtempSync(join(tmpdir(), 'hub-deploy-cwd-'))
169+
170+
// Vite-DevTools shape: hub at /__devtools/, devframes at their own
171+
// top-level bases (siblings of the hub base under the deploy root).
172+
const host = createH3DevframeHost({ origin: 'http://localhost', appName: 'devframes', workspaceRoot: cwd, mount: () => {} })
173+
const ctx = await createHubContext({ cwd, workspaceRoot: cwd, mode: 'build', host })
174+
await ctx.install(makeFrame('inspect', { distDir: makeDist('<h1>inspect</h1>') }), { base: '/__inspect/' })
175+
176+
await buildHub({ context: ctx, outDir, base: '/__devtools/', deployBase: '/', clean: false })
177+
178+
// Hub artifacts land under the hub base; the sibling frame beside it.
179+
expect(existsSync(join(outDir, '__devtools/__connection.json'))).toBe(true)
180+
expect(existsSync(join(outDir, '__devtools/__index.json'))).toBe(true)
181+
expect(existsSync(join(outDir, '__devtools/__rpc-dump/index.json'))).toBe(true)
182+
expect(readFileSync(join(outDir, '__inspect/index.html'), 'utf-8')).toContain('inspect')
183+
184+
// The frame meta points back at the hub's own meta at the hub base.
185+
const frameMeta = JSON.parse(readFileSync(join(outDir, '__inspect/__connection.json'), 'utf-8'))
186+
expect(frameMeta.baseUrl).toBe('/__devtools/__connection.json')
187+
const index = JSON.parse(readFileSync(join(outDir, '__devtools/__index.json'), 'utf-8'))
188+
expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['inspect'])
189+
})
190+
191+
it('rejects a hub base outside its deployBase', async () => {
192+
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
193+
await expect(buildHub({
194+
outDir,
195+
base: '/__hub/',
196+
deployBase: '/elsewhere/',
197+
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
198+
devframes: [makeFrame('alpha', { distDir: makeDist('<h1>alpha</h1>') })],
199+
})).rejects.toThrow(/outside the deploy root/)
200+
})
201+
166202
it('rejects a mount base outside the hub base', async () => {
167203
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
168204
await expect(buildHub({

packages/hub/src/node/build.ts

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ export interface BuildHubOptions {
3232
* pointers). Default: `/__devframes/`.
3333
*/
3434
base?: string
35+
/**
36+
* The deploy-root URL that {@link BuildHubOptions.outDir} maps to, so a
37+
* context whose devframe SPAs and static assets are served as **siblings**
38+
* of the hub {@link BuildHubOptions.base} (not children of it) still bakes.
39+
* The hub's own artifacts write under `base` within `outDir`; every other
40+
* mount base resolves against `outDir` by its path below this root. Must
41+
* contain `base`. Default: `base` (every mount lives under the hub base, the
42+
* built-in single-subtree layout).
43+
*
44+
* Vite DevTools serves the hub at `/__devtools/` but its devframes at their
45+
* own top-level bases (`/__<id>/`) and assets at `/__devtools-assets/`;
46+
* `deployBase: '/'` maps `outDir` to the deploy root so all of them land
47+
* beside the hub subtree.
48+
*/
49+
deployBase?: string
3550
/** Devframes to bake as docks, same input as `initHub({ devframes })`. */
3651
devframes?: DevframesInput
3752
/**
@@ -110,6 +125,9 @@ export async function buildHub(options: BuildHubOptions): Promise<void> {
110125
throw diagnostics.DF8002()
111126

112127
const base = normalizeHubBase(options.base ?? DEVFRAMES_HUB_BASE)
128+
const deployBase = options.deployBase ? normalizeHubBase(options.deployBase) : base
129+
if (!base.startsWith(deployBase))
130+
throw diagnostics.DF8007({ base, deployBase })
113131
const cwd = options.cwd ?? process.cwd()
114132
const outDir = resolve(cwd, options.outDir)
115133
const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? [])
@@ -119,27 +137,31 @@ export async function buildHub(options: BuildHubOptions): Promise<void> {
119137
await options.configure?.(ctx)
120138
await options.ui?.setup?.(ctx)
121139

122-
if (options.clean !== false && existsSync(outDir))
123-
await fs.rm(outDir, { recursive: true })
124-
await fs.mkdir(outDir, { recursive: true })
125-
126-
/** Map a hub-base-relative URL base to its on-disk location under `outDir`. */
140+
/** Map a served URL base to its on-disk location below the deploy root. */
127141
const resolveOutPath = (urlBase: string): string => {
128-
if (!urlBase.startsWith(base))
129-
throw diagnostics.DF8006({ urlBase, base })
130-
return resolve(outDir, urlBase.slice(base.length))
142+
if (!urlBase.startsWith(deployBase))
143+
throw diagnostics.DF8006({ urlBase, base: deployBase })
144+
return resolve(outDir, urlBase.slice(deployBase.length))
131145
}
132146

147+
// `outDir` is the deploy root; the hub's own artifacts live under `base`
148+
// within it (the same directory when no `deployBase` widens the layout).
149+
const hubOutDir = resolveOutPath(base)
150+
151+
if (options.clean !== false && existsSync(outDir))
152+
await fs.rm(outDir, { recursive: true })
153+
await fs.mkdir(hubOutDir, { recursive: true })
154+
133155
await copyBuildStatics(ctx, resolveOutPath)
134-
await publishRendererManifest(ctx, rendererRegistrations, base, outDir)
135-
await writeUiArtifacts(options.ui, outDir)
136-
await fs.writeFile(resolve(outDir, DEVFRAME_DOCK_IMPORTS_FILENAME), renderClientImportsModule(ctx), 'utf-8')
137-
await writeHubIndex(ctx, base, outDir, options)
138-
await writeConnectionMetas(ctx, base, outDir, resolveOutPath)
156+
await publishRendererManifest(ctx, rendererRegistrations, base, hubOutDir)
157+
await writeUiArtifacts(options.ui, hubOutDir)
158+
await fs.writeFile(resolve(hubOutDir, DEVFRAME_DOCK_IMPORTS_FILENAME), renderClientImportsModule(ctx), 'utf-8')
159+
await writeHubIndex(ctx, base, hubOutDir, options)
160+
await writeConnectionMetas(ctx, base, hubOutDir, resolveOutPath)
139161

140-
console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(outDir, '__rpc-dump')}`)
162+
console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(hubOutDir, '__rpc-dump')}`)
141163
const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx)
142-
await writeStaticRpcDump(dump, outDir, { pretty: options.pretty })
164+
await writeStaticRpcDump(dump, hubOutDir, { pretty: options.pretty })
143165

144166
const count = ctx.frames.length
145167
console.log(c.green`[devframes-hub] built ${count} devframe${count === 1 ? '' : 's'} -> ${outDir}`)
@@ -211,16 +233,16 @@ async function publishRendererManifest(
211233
ctx: DevframeHubContext,
212234
registrations: readonly DockRendererRegistration[],
213235
base: string,
214-
outDir: string,
236+
hubOutDir: string,
215237
): Promise<void> {
216238
const manifest: Record<string, ClientScriptEntry> = {}
217239
for (const registration of registrations) {
218240
manifest[registration.type] = {
219241
importFrom: joinURL(base, '__renderers', `${registration.type}.mjs`),
220242
...(registration.importName ? { importName: registration.importName } : {}),
221243
}
222-
await fs.mkdir(resolve(outDir, '__renderers'), { recursive: true })
223-
await fs.copyFile(registration.file, resolve(outDir, '__renderers', `${registration.type}.mjs`))
244+
await fs.mkdir(resolve(hubOutDir, '__renderers'), { recursive: true })
245+
await fs.copyFile(registration.file, resolve(hubOutDir, '__renderers', `${registration.type}.mjs`))
224246
}
225247
const manifestState = await ctx.rpc.sharedState.get<Record<string, ClientScriptEntry>>(
226248
DOCK_RENDERERS_STATE_KEY,
@@ -234,13 +256,13 @@ async function publishRendererManifest(
234256
* before the discovery documents, so those win over same-named files it
235257
* ships), `embedded.js` next to it, plus any produced assets.
236258
*/
237-
async function writeUiArtifacts(ui: DevframeHubUi | undefined, outDir: string): Promise<void> {
259+
async function writeUiArtifacts(ui: DevframeHubUi | undefined, hubOutDir: string): Promise<void> {
238260
if (ui?.viewer)
239-
await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true })
261+
await fs.cp(resolve(ui.viewer.distDir), hubOutDir, { recursive: true })
240262
if (ui?.embedded)
241-
await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js'))
263+
await fs.copyFile(resolve(ui.embedded.entry), resolve(hubOutDir, 'embedded.js'))
242264
for (const [key, produce] of Object.entries(ui?.assets ?? {})) {
243-
const target = resolve(outDir, key)
265+
const target = resolve(hubOutDir, key)
244266
await fs.mkdir(dirname(target), { recursive: true })
245267
await fs.writeFile(target, produce())
246268
}
@@ -250,10 +272,10 @@ async function writeUiArtifacts(ui: DevframeHubUi | undefined, outDir: string):
250272
async function writeHubIndex(
251273
ctx: DevframeHubContext,
252274
base: string,
253-
outDir: string,
275+
hubOutDir: string,
254276
options: BuildHubOptions,
255277
): Promise<void> {
256-
await fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({
278+
await fs.writeFile(resolve(hubOutDir, '__index.json'), `${JSON.stringify({
257279
name: options.name,
258280
version: options.version,
259281
base,
@@ -277,7 +299,7 @@ async function writeHubIndex(
277299
async function writeConnectionMetas(
278300
ctx: DevframeHubContext,
279301
base: string,
280-
outDir: string,
302+
hubOutDir: string,
281303
resolveOutPath: (urlBase: string) => string,
282304
): Promise<void> {
283305
const jsonSerializableMethods: string[] = []
@@ -290,7 +312,7 @@ async function writeConnectionMetas(
290312
jsonSerializableMethods,
291313
...(Object.keys(ctx.staticConfig).length > 0 ? { configs: ctx.staticConfig } : {}),
292314
}
293-
await fs.writeFile(resolve(outDir, DEVFRAME_CONNECTION_META_FILENAME), JSON.stringify(meta, null, 2), 'utf-8')
315+
await fs.writeFile(resolve(hubOutDir, DEVFRAME_CONNECTION_META_FILENAME), JSON.stringify(meta, null, 2), 'utf-8')
294316
const frameMeta: ConnectionMeta = { ...meta, baseUrl: joinURL(base, DEVFRAME_CONNECTION_META_FILENAME) }
295317
// A frame served its own SPA exactly when it registered a static mount at its
296318
// base; only those need a per-frame meta beside the copied SPA.

packages/hub/src/node/diagnostics.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ 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 can only write mounts under its deploy root: "${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 deploy root. Drop the `basePath` override (or the `ctx.install` base) that points outside it, or pass a wider `deployBase` (e.g. `/`) so `outDir` maps to a root that contains the mount.',
40+
},
41+
DF8007: {
42+
why: (p: { base: string, deployBase: string }) => `The hub base "${p.base}" is outside the deploy root "${p.deployBase}", so its artifacts have no place under \`outDir\`.`,
43+
fix: 'The hub base must live within `deployBase` (its artifacts write under `base` inside `outDir`). Move `base` under `deployBase`, or widen `deployBase` so it contains the hub base.',
4044
},
4145
DF8100: {
4246
why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,

tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export interface BuildHubOptions {
66
outDir: string;
77
base?: string;
8+
deployBase?: string;
89
devframes?: DevframesInput;
910
context?: DevframeHubContext;
1011
services?: DevframeServiceInput[];

0 commit comments

Comments
 (0)