diff --git a/docs/content/6.errors/DF8006.md b/docs/content/6.errors/DF8006.md index 2b964be1..43fb7660 100644 --- a/docs/content/6.errors/DF8006.md +++ b/docs/content/6.errors/DF8006.md @@ -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 @@ -26,8 +26,8 @@ await buildHub({ ## Fix - Drop the `base` override so the devframe mounts at `/`, 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. diff --git a/docs/content/6.errors/DF8007.md b/docs/content/6.errors/DF8007.md new file mode 100644 index 00000000..0fc8b31e --- /dev/null +++ b/docs/content/6.errors/DF8007.md @@ -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`. diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index d1264919..220775b1 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -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). | diff --git a/packages/hub/src/node/__tests__/build.test.ts b/packages/hub/src/node/__tests__/build.test.ts index 3be80ef6..af04d26b 100644 --- a/packages/hub/src/node/__tests__/build.test.ts +++ b/packages/hub/src/node/__tests__/build.test.ts @@ -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('

inspect

') }), { 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('

alpha

') })], + })).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({ diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index 6aa6e260..58cb0bf6 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -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 (`/__/`) 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 /** @@ -110,6 +125,9 @@ export async function buildHub(options: BuildHubOptions): Promise { 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 ?? []) @@ -119,27 +137,31 @@ export async function buildHub(options: BuildHubOptions): Promise { 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}`) @@ -211,7 +233,7 @@ async function publishRendererManifest( ctx: DevframeHubContext, registrations: readonly DockRendererRegistration[], base: string, - outDir: string, + hubOutDir: string, ): Promise { const manifest: Record = {} for (const registration of registrations) { @@ -219,8 +241,8 @@ async function publishRendererManifest( 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>( DOCK_RENDERERS_STATE_KEY, @@ -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 { +async function writeUiArtifacts(ui: DevframeHubUi | undefined, hubOutDir: string): Promise { 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()) } @@ -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 { - 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, @@ -277,7 +299,7 @@ async function writeHubIndex( async function writeConnectionMetas( ctx: DevframeHubContext, base: string, - outDir: string, + hubOutDir: string, resolveOutPath: (urlBase: string) => string, ): Promise { const jsonSerializableMethods: string[] = [] @@ -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. diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index c796f327..cd3d88ad 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -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`, diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts index 4178d6dd..5553f68f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts @@ -5,6 +5,7 @@ export interface BuildHubOptions { outDir: string; base?: string; + deployBase?: string; devframes?: DevframesInput; context?: DevframeHubContext; services?: DevframeServiceInput[];