diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 7eca5d28..bfbd648c 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -113,6 +113,8 @@ await buildHub({ }) ``` +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. + 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. 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. diff --git a/docs/content/6.errors/DF8006.md b/docs/content/6.errors/DF8006.md index 2b964be1..9ee2df1e 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 Base Is Not Absolute' +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.' --- ## Message -> A static hub build can only write mounts under its own base: "`{urlBase}`" escapes "`{base}`" +> A static hub build writes each mount either under its base ("`{base}`") or as an absolute-path sibling of it, but "`{urlBase}`" is neither ## 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 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. ## Example @@ -17,17 +17,22 @@ description: 'A static hub build can only write mounts under its own base: "{url await buildHub({ outDir: 'dist/__devframes', async configure(ctx) { - // ✗ Bad: `/tools/x/` is not under the `/__devframes/` hub base - await ctx.install(myDevframe, { base: '/tools/x/' }) + // ✓ Good: under the hub base, written into `outDir` + await ctx.install(a, { base: '/__devframes/a/' }) + // ✓ Good: an absolute sibling, written to the deploy root beside the hub + await ctx.install(b, { base: '/tools/b/' }) + // ✗ Bad: a relative base resolves against neither + await ctx.install(c, { base: 'tools/c/' }) }, }) ``` ## 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. +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. ## 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 a mount base that is not absolute. + + diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index d1264919..bb1ee1bf 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -90,7 +90,7 @@ 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 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. | | `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. | | `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. | diff --git a/packages/hub/src/node/__tests__/build.test.ts b/packages/hub/src/node/__tests__/build.test.ts index 3be80ef6..7ac4c82c 100644 --- a/packages/hub/src/node/__tests__/build.test.ts +++ b/packages/hub/src/node/__tests__/build.test.ts @@ -163,15 +163,27 @@ describe('buildHub', () => { expect(manifest['alpha:probe']).toMatchObject({ type: 'static' }) }) - it('rejects a mount base outside the hub base', async () => { - const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub') - await expect(buildHub({ + it('bakes a mount outside the hub base as a deploy-root sibling', async () => { + const deployRoot = mkdtempSync(join(tmpdir(), 'hub-build-out-')) + const outDir = join(deployRoot, '__hub') + + await buildHub({ outDir, base: '/__hub/', cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')), async configure(ctx) { - await ctx.install(makeFrame('gamma', { distDir: makeDist('

gamma

') }), { base: '/elsewhere/' }) + await ctx.install(makeFrame('gamma', { distDir: makeDist('

gamma

') }), { base: '/gamma/' }) }, - })).rejects.toThrow(/escapes "\/__hub\/"/) + }) + + // The hub subtree lands at `outDir`, while the sibling frame resolves to + // the deploy root (outDir's parent) by its absolute path, beside the hub. + expect(existsSync(join(outDir, '__connection.json'))).toBe(true) + expect(readFileSync(join(deployRoot, 'gamma/index.html'), 'utf-8')).toContain('gamma') + // The sibling frame still gets its per-frame meta pointing back at the hub. + const frameMeta = JSON.parse(readFileSync(join(deployRoot, 'gamma/__connection.json'), 'utf-8')) + expect(frameMeta.baseUrl).toBe('/__hub/__connection.json') + const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8')) + expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['gamma']) }) }) diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index 6aa6e260..d9f2cbab 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -23,7 +23,10 @@ export interface BuildHubOptions { * Output directory the hub subtree is written into. It corresponds to the * hub {@link BuildHubOptions.base} at serve time: building with * `base: '/__devframes/'` into `dist/__devframes` makes the deployed app's - * `dist/` servable as-is by any static file server. + * `dist/` servable as-is by any static file server. 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, + * so `dist/` still serves the whole layout. */ outDir: string /** @@ -123,11 +126,21 @@ export async function buildHub(options: BuildHubOptions): Promise { 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. A base under the hub + * {@link base} writes into the hub subtree at `outDir` (so `outDir` + * corresponds to the hub base). A base outside it is a deploy-root sibling: + * `outDir`'s parent is the deploy root, and the base resolves under it by its + * absolute path. This is the layout Vite DevTools serves, where devframe SPAs + * and assets sit beside `/__devtools/` rather than under it. + */ + const deployRoot = dirname(outDir) const resolveOutPath = (urlBase: string): string => { - if (!urlBase.startsWith(base)) + if (urlBase.startsWith(base)) + return resolve(outDir, urlBase.slice(base.length)) + if (!urlBase.startsWith('/')) throw diagnostics.DF8006({ urlBase, base }) - return resolve(outDir, urlBase.slice(base.length)) + return resolve(deployRoot, urlBase.slice(1)) } await copyBuildStatics(ctx, resolveOutPath) diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index c796f327..fdef08e6 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -35,8 +35,8 @@ 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 writes each mount either under its base ("${p.base}") or as an absolute-path sibling of it, but "${p.urlBase}" is neither.`, + 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.', }, DF8100: { why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,