Skip to content
Closed
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
12 changes: 6 additions & 6 deletions docs/content/6.errors/DF8006.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
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}".'
title: 'DF8006: Static Build Mount Escapes the Deploy Root'
description: 'A static hub build can only write mounts under its deploy root: "{urlBase}" escapes "{base}".'
---

## Message

> A static hub build can only write mounts under its own base: "`{urlBase}`" escapes "`{base}`"
> A static hub build can only write mounts under its deploy root: "`{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`.
`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`.

## Example

Expand All @@ -26,8 +26,8 @@ await buildHub({
## 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.
- 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`.

## 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.
- [`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.
32 changes: 32 additions & 0 deletions docs/content/6.errors/DF8007.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: 'DF8007: Hub Base Outside the Deploy Root'
description: 'The hub base "{base}" is outside the deploy root "{deployBase}", so its artifacts have no place under `outDir`.'
---

## Message

> The hub base "`{base}`" is outside the deploy root "`{deployBase}`", so its artifacts have no place under `outDir`

## Cause

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

## Example

```ts
await buildHub({
outDir: 'dist',
base: '/__hub/',
/** ✗ Bad: `/__hub/` is not under `/elsewhere/` */
deployBase: '/elsewhere/',
})
```

## Fix

- Move `base` under `deployBase` (the hub base must be a child of the deploy root).
- Or widen `deployBase` so it contains the hub base (e.g. `deployBase: '/'`).

## Source

- [`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`.
3 changes: 2 additions & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/

| Option | Purpose |
|---|---|
| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). |
| `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). |
| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
| `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`. |
| `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`. |
| `clean` | Remove `outDir` before writing. Default `true`; set `false` to bake beside an app's own build output. |
| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |
Expand Down
36 changes: 36 additions & 0 deletions packages/hub/src/node/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,42 @@ describe('buildHub', () => {
expect(manifest['alpha:probe']).toMatchObject({ type: 'static' })
})

it('bakes a sibling-layout context with deployBase mapping outDir to the deploy root', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'hub-deploy-out-'))
const cwd = mkdtempSync(join(tmpdir(), 'hub-deploy-cwd-'))

// Vite-DevTools shape: hub at /__devtools/, devframes at their own
// top-level bases (siblings of the hub base under the deploy root).
const host = createH3DevframeHost({ origin: 'http://localhost', appName: 'devframes', workspaceRoot: cwd, mount: () => {} })
const ctx = await createHubContext({ cwd, workspaceRoot: cwd, mode: 'build', host })
await ctx.install(makeFrame('inspect', { distDir: makeDist('<h1>inspect</h1>') }), { base: '/__inspect/' })

await buildHub({ context: ctx, outDir, base: '/__devtools/', deployBase: '/', clean: false })

// Hub artifacts land under the hub base; the sibling frame beside it.
expect(existsSync(join(outDir, '__devtools/__connection.json'))).toBe(true)
expect(existsSync(join(outDir, '__devtools/__index.json'))).toBe(true)
expect(existsSync(join(outDir, '__devtools/__rpc-dump/index.json'))).toBe(true)
expect(readFileSync(join(outDir, '__inspect/index.html'), 'utf-8')).toContain('inspect')

// The frame meta points back at the hub's own meta at the hub base.
const frameMeta = JSON.parse(readFileSync(join(outDir, '__inspect/__connection.json'), 'utf-8'))
expect(frameMeta.baseUrl).toBe('/__devtools/__connection.json')
const index = JSON.parse(readFileSync(join(outDir, '__devtools/__index.json'), 'utf-8'))
expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['inspect'])
})

it('rejects a hub base outside its deployBase', async () => {
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
await expect(buildHub({
outDir,
base: '/__hub/',
deployBase: '/elsewhere/',
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
devframes: [makeFrame('alpha', { distDir: makeDist('<h1>alpha</h1>') })],
})).rejects.toThrow(/outside the deploy root/)
})

it('rejects a mount base outside the hub base', async () => {
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
await expect(buildHub({
Expand Down
74 changes: 48 additions & 26 deletions packages/hub/src/node/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ export interface BuildHubOptions {
* pointers). Default: `/__devframes/`.
*/
base?: string
/**
* The deploy-root URL that {@link BuildHubOptions.outDir} maps to, so a
* context whose devframe SPAs and static assets are served as **siblings**
* of the hub {@link BuildHubOptions.base} (not children of it) still bakes.
* The hub's own artifacts write under `base` within `outDir`; every other
* mount base resolves against `outDir` by its path below this root. Must
* contain `base`. Default: `base` (every mount lives under the hub base, the
* built-in single-subtree layout).
*
* Vite DevTools serves the hub at `/__devtools/` but its devframes at their
* own top-level bases (`/__<id>/`) and assets at `/__devtools-assets/`;
* `deployBase: '/'` maps `outDir` to the deploy root so all of them land
* beside the hub subtree.
*/
deployBase?: string
/** Devframes to bake as docks, same input as `initHub({ devframes })`. */
devframes?: DevframesInput
/**
Expand Down Expand Up @@ -110,6 +125,9 @@ export async function buildHub(options: BuildHubOptions): Promise<void> {
throw diagnostics.DF8002()

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

if (options.clean !== false && existsSync(outDir))
await fs.rm(outDir, { recursive: true })
await fs.mkdir(outDir, { recursive: true })

/** Map a hub-base-relative URL base to its on-disk location under `outDir`. */
/** Map a served URL base to its on-disk location below the deploy root. */
const resolveOutPath = (urlBase: string): string => {
if (!urlBase.startsWith(base))
throw diagnostics.DF8006({ urlBase, base })
return resolve(outDir, urlBase.slice(base.length))
if (!urlBase.startsWith(deployBase))
throw diagnostics.DF8006({ urlBase, base: deployBase })
return resolve(outDir, urlBase.slice(deployBase.length))
}

// `outDir` is the deploy root; the hub's own artifacts live under `base`
// within it (the same directory when no `deployBase` widens the layout).
const hubOutDir = resolveOutPath(base)

if (options.clean !== false && existsSync(outDir))
await fs.rm(outDir, { recursive: true })
await fs.mkdir(hubOutDir, { recursive: true })

await copyBuildStatics(ctx, resolveOutPath)
await publishRendererManifest(ctx, rendererRegistrations, base, outDir)
await writeUiArtifacts(options.ui, outDir)
await fs.writeFile(resolve(outDir, DEVFRAME_DOCK_IMPORTS_FILENAME), renderClientImportsModule(ctx), 'utf-8')
await writeHubIndex(ctx, base, outDir, options)
await writeConnectionMetas(ctx, base, outDir, resolveOutPath)
await publishRendererManifest(ctx, rendererRegistrations, base, hubOutDir)
await writeUiArtifacts(options.ui, hubOutDir)
await fs.writeFile(resolve(hubOutDir, DEVFRAME_DOCK_IMPORTS_FILENAME), renderClientImportsModule(ctx), 'utf-8')
await writeHubIndex(ctx, base, hubOutDir, options)
await writeConnectionMetas(ctx, base, hubOutDir, resolveOutPath)

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

const count = ctx.frames.length
console.log(c.green`[devframes-hub] built ${count} devframe${count === 1 ? '' : 's'} -> ${outDir}`)
Expand Down Expand Up @@ -211,16 +233,16 @@ async function publishRendererManifest(
ctx: DevframeHubContext,
registrations: readonly DockRendererRegistration[],
base: string,
outDir: string,
hubOutDir: string,
): Promise<void> {
const manifest: Record<string, ClientScriptEntry> = {}
for (const registration of registrations) {
manifest[registration.type] = {
importFrom: joinURL(base, '__renderers', `${registration.type}.mjs`),
...(registration.importName ? { importName: registration.importName } : {}),
}
await fs.mkdir(resolve(outDir, '__renderers'), { recursive: true })
await fs.copyFile(registration.file, resolve(outDir, '__renderers', `${registration.type}.mjs`))
await fs.mkdir(resolve(hubOutDir, '__renderers'), { recursive: true })
await fs.copyFile(registration.file, resolve(hubOutDir, '__renderers', `${registration.type}.mjs`))
}
const manifestState = await ctx.rpc.sharedState.get<Record<string, ClientScriptEntry>>(
DOCK_RENDERERS_STATE_KEY,
Expand All @@ -234,13 +256,13 @@ async function publishRendererManifest(
* before the discovery documents, so those win over same-named files it
* ships), `embedded.js` next to it, plus any produced assets.
*/
async function writeUiArtifacts(ui: DevframeHubUi | undefined, outDir: string): Promise<void> {
async function writeUiArtifacts(ui: DevframeHubUi | undefined, hubOutDir: string): Promise<void> {
if (ui?.viewer)
await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true })
await fs.cp(resolve(ui.viewer.distDir), hubOutDir, { recursive: true })
if (ui?.embedded)
await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js'))
await fs.copyFile(resolve(ui.embedded.entry), resolve(hubOutDir, 'embedded.js'))
for (const [key, produce] of Object.entries(ui?.assets ?? {})) {
const target = resolve(outDir, key)
const target = resolve(hubOutDir, key)
await fs.mkdir(dirname(target), { recursive: true })
await fs.writeFile(target, produce())
}
Expand All @@ -250,10 +272,10 @@ async function writeUiArtifacts(ui: DevframeHubUi | undefined, outDir: string):
async function writeHubIndex(
ctx: DevframeHubContext,
base: string,
outDir: string,
hubOutDir: string,
options: BuildHubOptions,
): Promise<void> {
await fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({
await fs.writeFile(resolve(hubOutDir, '__index.json'), `${JSON.stringify({
name: options.name,
version: options.version,
base,
Expand All @@ -277,7 +299,7 @@ async function writeHubIndex(
async function writeConnectionMetas(
ctx: DevframeHubContext,
base: string,
outDir: string,
hubOutDir: string,
resolveOutPath: (urlBase: string) => string,
): Promise<void> {
const jsonSerializableMethods: string[] = []
Expand All @@ -290,7 +312,7 @@ async function writeConnectionMetas(
jsonSerializableMethods,
...(Object.keys(ctx.staticConfig).length > 0 ? { configs: ctx.staticConfig } : {}),
}
await fs.writeFile(resolve(outDir, DEVFRAME_CONNECTION_META_FILENAME), JSON.stringify(meta, null, 2), 'utf-8')
await fs.writeFile(resolve(hubOutDir, DEVFRAME_CONNECTION_META_FILENAME), JSON.stringify(meta, null, 2), 'utf-8')
const frameMeta: ConnectionMeta = { ...meta, baseUrl: joinURL(base, DEVFRAME_CONNECTION_META_FILENAME) }
// A frame served its own SPA exactly when it registered a static mount at its
// base; only those need a per-frame meta beside the copied SPA.
Expand Down
8 changes: 6 additions & 2 deletions packages/hub/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ export const diagnostics = defineDiagnostics({
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.',
},
DF8006: {
why: (p: { urlBase: string, base: string }) => `A static hub build can only write mounts under its own base: "${p.urlBase}" escapes "${p.base}".`,
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.',
why: (p: { urlBase: string, base: string }) => `A static hub build can only write mounts under its deploy root: "${p.urlBase}" escapes "${p.base}".`,
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.',
},
DF8007: {
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\`.`,
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.',
},
DF8100: {
why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
export interface BuildHubOptions {
outDir: string;
base?: string;
deployBase?: string;
devframes?: DevframesInput;
context?: DevframeHubContext;
services?: DevframeServiceInput[];
Expand Down
Loading