From 5bafc33f46e9624411e791f230beff51f879fb37 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 13:00:56 +0000 Subject: [PATCH 1/5] feat(hub): expose bakeHubStatic to bake an already-mounted context Extract buildHub's post-mount baker into a public bakeHubStatic(ctx, opts) so a host that assembles and mounts its own hub context (Vite DevTools' kit-augmented context, devframes mounted from Vite plugins) reuses the exact baker instead of reimplementing it and drifting out of sync. - bakeHubStatic bakes an already-mounted DevframeHubContext; buildHub is now createHubContext + mountDevframes + bakeHubStatic. - Enumerate mounted frames via ctx.frames (HubMountedFrame), populated in prepareDevframe, so an externally-mounted context can emit __index.json and per-frame __connection.json. - Materialize statics from ctx.views.buildStaticDirs (each entry now carries its resolveFrom); route page scripts through views.hostStatic so they land there too. - Add a clean opt-out so the baker can write beside an app's own build output. --- docs/content/1.guide/18.hub-initiate.md | 2 + docs/content/6.errors/DF8006.md | 4 +- docs/content/8.references/6.hub-api.md | 5 + packages/devframe/src/node/host-views.ts | 4 +- packages/devframe/src/types/views.ts | 8 +- packages/hub/src/node/__tests__/build.test.ts | 53 ++++ .../node/__tests__/install-devframe.test.ts | 14 +- packages/hub/src/node/assemble.ts | 10 +- packages/hub/src/node/bake.ts | 221 +++++++++++++++++ packages/hub/src/node/build.ts | 228 ++++-------------- packages/hub/src/node/context.ts | 30 +++ packages/hub/src/node/initiate.ts | 5 +- packages/hub/src/node/install-devframe.ts | 18 +- 13 files changed, 397 insertions(+), 205 deletions(-) create mode 100644 packages/hub/src/node/bake.ts diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 843e242b0..7b30ad91f 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -126,3 +126,5 @@ const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx }) ``` It then serves only hub-level endpoints and transport; serve each mounted devframe's meta from `hub.connectionMeta()` yourself. + +The same context works for a static build: `bakeHubStatic(ctx, { outDir, base })` from `@devframes/hub/build` bakes an already-mounted context (`buildHub` is `createHubContext` + `mountDevframes` + `bakeHubStatic`). It reads `ctx.views.buildStaticDirs` for the statics to copy and `ctx.frames` for the frames to advertise, so a host that mounted its own context reuses the exact baker rather than reimplementing it. Pass `clean: false` to bake beside an app's own build output. diff --git a/docs/content/6.errors/DF8006.md b/docs/content/6.errors/DF8006.md index 2b964be16..dc576b41c 100644 --- a/docs/content/6.errors/DF8006.md +++ b/docs/content/6.errors/DF8006.md @@ -9,7 +9,7 @@ description: 'A static hub build can only write mounts under its own base: "{url ## 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`. +`bakeHubStatic` (which `buildHub` runs) 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`. ## Example @@ -30,4 +30,4 @@ await buildHub({ ## 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/bake.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/bake.ts): `bakeHubStatic()`'s mount-to-disk mapping throws this for any mount base outside the hub base. diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index b5b3cfb33..902533e66 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -92,8 +92,13 @@ The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/ |---|---| | `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). | | `base` | Mount base baked into every absolute URL the build emits. Default `/__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). | +## `bakeHubStatic` options + +`bakeHubStatic(ctx, options)` from `@devframes/hub/build` is the second half of `buildHub`: it bakes an already-mounted `DevframeHubContext` a caller assembled itself (`createHubContext` + `ctx.install`, or a framework kit's own context), reading `ctx.views.buildStaticDirs` for the statics to copy and `ctx.frames` for the frames to advertise. `buildHub` is `createHubContext` + `mountDevframes` + `bakeHubStatic`. Options: `outDir`, `base`, `ui`, `renderers`, `name`, `version`, `pretty`, and `clean`, same contracts as their `buildHub` counterparts. + ## Client runtime options The options of `createDevframeClientRuntime()`: [The client runtime](/guide/client-context#the-client-runtime). diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index d7e18d123..bfa668fc7 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -7,7 +7,7 @@ export class DevframeViewHost implements DevframeViewHostType { /** * @internal */ - public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] = [] + public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] = [] constructor( public readonly context: DevframeNodeContext, @@ -30,7 +30,7 @@ export class DevframeViewHost implements DevframeViewHostType { throw diagnostics.DF0008({ distDir: resolved }) } - this.buildStaticDirs.push({ baseUrl, source }) + this.buildStaticDirs.push({ baseUrl, source, resolveFrom: defaultResolveFrom }) this.context.host.mountStatic(baseUrl, resolved) } } diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index dd5c03839..9e8dc2384 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -2,9 +2,15 @@ import type { StaticAssetsSource } from './remote-assets' export interface DevframeViewHost { /** + * Static mounts registered through {@link DevframeViewHost.hostStatic}, in + * registration order, each carrying the `resolveFrom` base it was mounted + * with so a build step can re-resolve a remote source identically. A static + * build that assembles the context itself (rather than serving it live) + * copies these into its output. + * * @internal */ - buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] + buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] /** * Helper to host static files * - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files diff --git a/packages/hub/src/node/__tests__/build.test.ts b/packages/hub/src/node/__tests__/build.test.ts index cbe9539f0..797f337f7 100644 --- a/packages/hub/src/node/__tests__/build.test.ts +++ b/packages/hub/src/node/__tests__/build.test.ts @@ -2,9 +2,12 @@ import type { DevframeDefinition, DevframeNodeContext } from 'devframe/types' import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createH3DevframeHost } from 'devframe/internal' import { describe, expect, it } from 'vitest' import { HUB_EVENTS } from '../../events' +import { bakeHubStatic } from '../bake' import { buildHub } from '../build' +import { createHubContext } from '../context' function makeDist(html: string): string { const dir = mkdtempSync(join(tmpdir(), 'hub-build-dist-')) @@ -111,6 +114,56 @@ describe('buildHub', () => { expect(docksRecord).not.toContain('Frame live') }) + it('keeps sibling output when clean is false', async () => { + const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub') + const appFile = join(outDir, 'app.js') + + await buildHub({ + outDir, + base: '/__hub/', + cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')), + devframes: [makeFrame('alpha', { distDir: makeDist('

alpha

') })], + }) + writeFileSync(appFile, 'app', 'utf-8') + + await buildHub({ + outDir, + base: '/__hub/', + clean: false, + cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')), + devframes: [makeFrame('beta', { distDir: makeDist('

beta

') })], + }) + + // The pre-existing sibling file survives, and the re-bake lands beside it. + expect(existsSync(appFile)).toBe(true) + expect(readFileSync(join(outDir, 'beta/index.html'), 'utf-8')).toContain('beta') + }) + + it('bakes an externally-mounted context via bakeHubStatic', async () => { + const outDir = join(mkdtempSync(join(tmpdir(), 'hub-bake-out-')), 'hub') + const cwd = mkdtempSync(join(tmpdir(), 'hub-bake-cwd-')) + + // A host assembling the context itself: create + mount via `ctx.install`, + // then hand the already-mounted context to the baker. + 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('alpha', { distDir: makeDist('

alpha

') }), { base: '/__hub/alpha/' }) + + expect(ctx.frames.map(frame => frame.id)).toEqual(['alpha']) + + await bakeHubStatic(ctx, { outDir, base: '/__hub/' }) + + // The baker copied the SPA from `ctx.views.buildStaticDirs`, wrote the + // index from `ctx.frames`, and emitted the per-frame meta + shared dump. + expect(readFileSync(join(outDir, 'alpha/index.html'), 'utf-8')).toContain('alpha') + const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8')) + expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['alpha']) + const frameMeta = JSON.parse(readFileSync(join(outDir, 'alpha/__connection.json'), 'utf-8')) + expect(frameMeta.baseUrl).toBe('/__hub/__connection.json') + const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8')) + 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({ diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts index 61cc0ca76..5c200d7d0 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -12,15 +12,25 @@ type DeepPartial = { [K in keyof T]?: DeepPartial } function createContext(): DevframeHubContext { const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-install-')) + const mountStatic = vi.fn() const partial: DeepPartial = { host: { - mountStatic: vi.fn(), + mountStatic, resolveOrigin: () => 'http://localhost:5173', getStorageDir: () => storageDir, }, views: { - hostStatic: () => {}, + /** + * Mirror the real view host: forward to `host.mountStatic` so the tests + * assert the static mount the same way they did before page scripts and + * SPAs routed through `views.hostStatic`. + */ + hostStatic: vi.fn((baseUrl: string, source: unknown) => { + mountStatic(baseUrl, source as string) + }), + buildStaticDirs: [], }, + frames: [], /** * Minimal stub, since these tests drive dock/setup wiring, not the services * lifecycle (the demo devframe declares none). diff --git a/packages/hub/src/node/assemble.ts b/packages/hub/src/node/assemble.ts index 914a87a37..fea387077 100644 --- a/packages/hub/src/node/assemble.ts +++ b/packages/hub/src/node/assemble.ts @@ -8,7 +8,7 @@ import { resolve } from 'pathe' import { joinURL, withTrailingSlash } from 'ufo' import { resolveClientModuleSpecifier } from '../client-modules' import { diagnostics } from './diagnostics' -import { prepareDevframe, skippedInStaticBuild } from './install-devframe' +import { prepareDevframe } from './install-devframe' /** Reserved filenames directly under the hub base; a frame id can't shadow them. */ const RESERVED_HUB_PATHS = [ @@ -100,13 +100,13 @@ export function renderClientImportsModule(ctx: DevframeHubContext): string { /** * Pass 1: mount each devframe under `/` (SPA, meta, iframe dock) * and queue its declared services, guarding the id against reserved hub - * filenames and route-pattern characters. Returns the deferred setup thunks. + * filenames and route-pattern characters. Returns the deferred setup thunks; + * each mounted frame is recorded on `ctx.frames`. */ export async function mountDevframes( ctx: DevframeHubContext, devframes: HubDevframeEntry[], base: string, - frames: { id: string, base: string, title: string }[], hubMcpEnabled: boolean, ): Promise<(() => Promise)[]> { const setups: (() => Promise)[] = [] @@ -129,10 +129,6 @@ export async function mountDevframes( const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) }) if (run) setups.push(run) - // A devframe skipped by the static build serves nothing, so it never - // joins the `__index.json` frame list either. - if (!skippedInStaticBuild(ctx, def)) - frames.push({ id: def.id, base: frameBase, title: def.name }) } return setups } diff --git a/packages/hub/src/node/bake.ts b/packages/hub/src/node/bake.ts new file mode 100644 index 000000000..28cc9d1ad --- /dev/null +++ b/packages/hub/src/node/bake.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-console */ +import type { ConnectionMeta } from 'devframe/types' +import type { ClientScriptEntry } from '../types/docks' +import type { DevframeHubContext } from './context' +import type { DevframeHubUi, DockRendererRegistration } from './initiate' +import { existsSync } from 'node:fs' +import fs from 'node:fs/promises' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME } from 'devframe/constants' +import { collectStaticRpcDump, writeStaticRpcDump } from 'devframe/rpc/dump' +import { colors as c } from 'devframe/utils/colors' +import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' +import { dirname, resolve } from 'pathe' +import { joinURL } from 'ufo' +import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants' +import { renderClientImportsModule, resolveRendererRegistrations } from './assemble' +import { diagnostics } from './diagnostics' + +export interface BakeHubStaticOptions { + /** + * Output directory the hub subtree is written into, the on-disk counterpart + * of {@link BakeHubStaticOptions.base}. + */ + outDir: string + /** + * Mount base the deployed hub answers under, baked into every absolute URL + * the build emits. Default: `/__devframes/`. + */ + base?: string + /** + * The hub's UI slot: its viewer SPA is copied to the hub base, `embedded.js` + * next to it, and any produced assets written. `setup(ctx)` is the caller's + * responsibility (it must run before baking so its static config is in the + * connection meta). + */ + ui?: DevframeHubUi + /** Prebuilt dock-renderer modules, copied to `__renderers/.mjs`. */ + renderers?: readonly DockRendererRegistration[] + /** Name written into `__index.json`. */ + name?: string + /** Version written into `__index.json`. */ + version?: string + /** Pretty-print RPC dump JSON files. Default: `false` (minified shards). */ + pretty?: boolean + /** + * Remove {@link BakeHubStaticOptions.outDir} before writing. Default `true`, + * matching a from-scratch build. Pass `false` to bake into a directory that + * already holds sibling output (an app's own `dist/` the hub subtree lives + * beside). + */ + clean?: boolean +} + +/** + * Bake an already-mounted hub context into a self-contained static deploy: the + * tail half of `buildHub` factored out so a host that assembled and mounted the + * context itself (Vite DevTools' kit-augmented context, a devframe mounted from + * a Vite plugin's `devtools.setup`) reuses the exact baker instead of + * reimplementing it. + * + * The caller owns context creation, devframe mounting, `configure`, and the UI + * slot's `setup`; this reads what they left behind: + * + * - copies every static registered through `ctx.views.hostStatic` (frame + * SPAs, page scripts, hub statics) from `ctx.views.buildStaticDirs`; + * - publishes the renderer manifest and copies the renderer modules; + * - copies the UI slot's viewer SPA, `embedded.js`, and produced assets; + * - writes the discovery documents (`__client-imports.js`, `__index.json` + * from `ctx.frames`); + * - writes `__connection.json` (`{ backend: 'static' }`) at the hub base and + * at every frame base that served its own SPA; + * - bakes the shared RPC dump under `/__rpc-dump/`. + */ +export async function bakeHubStatic(ctx: DevframeHubContext, options: BakeHubStaticOptions): Promise { + const base = normalizeHubBase(options.base ?? DEVFRAMES_HUB_BASE) + const outDir = resolve(options.outDir) + const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) + + 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`. */ + const resolveOutPath = (urlBase: string): string => { + if (!urlBase.startsWith(base)) + throw diagnostics.DF8006({ urlBase, base }) + return resolve(outDir, urlBase.slice(base.length)) + } + + 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) + + console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(outDir, '__rpc-dump')}`) + const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) + await writeStaticRpcDump(dump, outDir, { pretty: options.pretty }) + + const count = ctx.frames.length + console.log(c.green`[devframes-hub] built ${count} devframe${count === 1 ? '' : 's'} -> ${outDir}`) +} + +/** + * Copy every static the context registered through `ctx.views.hostStatic` + * (recorded in `ctx.views.buildStaticDirs`): a local dir verbatim, a remote + * source by materializing every listed file. Reads the list rather than + * relying on a live host `mountStatic`, so a context whose host baked no + * statics at mount time (a kit build host) still gets its assets copied. + */ +async function copyBuildStatics(ctx: DevframeHubContext, resolveOutPath: (urlBase: string) => string): Promise { + const storageDir = ctx.host.getStorageDir('project') + for (const { baseUrl, source, resolveFrom } of ctx.views.buildStaticDirs) { + const target = resolveOutPath(baseUrl) + const resolved = resolveStaticAssetsSource(source, storageDir, resolveFrom) + await fs.mkdir(dirname(target), { recursive: true }) + if (typeof resolved === 'string') + await fs.cp(resolved, target, { recursive: true }) + else + await resolved.materialize(target) + } +} + +/** + * Publish the renderer manifest exactly like `initHub` does, so it is baked + * into the shared-state snapshot, and copy each prebuilt module to the URL + * the manifest advertises. + */ +async function publishRendererManifest( + ctx: DevframeHubContext, + registrations: readonly DockRendererRegistration[], + base: string, + outDir: string, +): Promise { + const manifest: Record = {} + 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`)) + } + const manifestState = await ctx.rpc.sharedState.get>( + DOCK_RENDERERS_STATE_KEY, + { initialValue: {} }, + ) + manifestState.mutate(() => manifest) +} + +/** + * Copy the UI slot's artifacts: the viewer SPA owns the hub base (copied + * 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 { + if (ui?.viewer) + await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true }) + if (ui?.embedded) + await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js')) + for (const [key, produce] of Object.entries(ui?.assets ?? {})) { + const target = resolve(outDir, key) + await fs.mkdir(dirname(target), { recursive: true }) + await fs.writeFile(target, produce()) + } +} + +/** Write `__index.json`: the discovery document listing every frame. */ +async function writeHubIndex( + ctx: DevframeHubContext, + base: string, + outDir: string, + options: BakeHubStaticOptions, +): Promise { + await fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({ + name: options.name, + version: options.version, + base, + frames: ctx.frames.map(({ id, base, title }) => ({ id, base, title })), + endpoints: { + connection: DEVFRAME_CONNECTION_META_FILENAME, + clientImports: DEVFRAME_DOCK_IMPORTS_FILENAME, + index: '__index.json', + ...(options.ui?.embedded ? { embedded: 'embedded.js' } : {}), + }, + }, null, 2)}\n`, 'utf-8') +} + +/** + * Write the `backend: 'static'` connection meta at the hub base, and a copy at + * every frame base that served its own SPA whose `baseUrl` points relative + * resolution (the RPC dump) back at the hub's own meta, so a frame SPA that + * fetched its per-frame copy (instead of inheriting the host page's connection) + * still finds the shared dump. + */ +async function writeConnectionMetas( + ctx: DevframeHubContext, + base: string, + outDir: string, + resolveOutPath: (urlBase: string) => string, +): Promise { + const jsonSerializableMethods: string[] = [] + for (const def of ctx.rpc.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + const meta: ConnectionMeta = { + backend: 'static', + 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') + const frameMeta: ConnectionMeta = { ...meta, baseUrl: joinURL(base, DEVFRAME_CONNECTION_META_FILENAME) } + for (const frame of ctx.frames) { + if (!frame.hasClientAssets) + continue + const target = resolve(resolveOutPath(frame.base), DEVFRAME_CONNECTION_META_FILENAME) + await fs.mkdir(dirname(target), { recursive: true }) + await fs.writeFile(target, JSON.stringify(frameMeta, null, 2), 'utf-8') + } +} diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index b22305c36..561117b75 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -1,23 +1,19 @@ -/* eslint-disable no-console */ -import type { ConnectionMeta, DevframeServiceInput, DevframeStorageScope } from 'devframe/types' -import type { ClientScriptEntry } from '../types/docks' +import type { DevframeServiceInput, DevframeStorageScope } from 'devframe/types' +import type { BakeHubStaticOptions } from './bake' import type { CreateHubContextOptions, DevframeHubContext } from './context' -import type { DevframeHubUi, DevframesInput, DockRendererRegistration } from './initiate' -import { existsSync } from 'node:fs' -import fs from 'node:fs/promises' +import type { DevframeHubUi, DevframesInput } from './initiate' import process from 'node:process' -import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME } from 'devframe/constants' import { createH3DevframeHost } from 'devframe/internal' -import { collectStaticRpcDump, writeStaticRpcDump } from 'devframe/rpc/dump' -import { colors as c } from 'devframe/utils/colors' -import { dirname, resolve } from 'pathe' -import { joinURL } from 'ufo' -import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants' -import { mountDevframes, renderClientImportsModule, resolveDevframesInput, resolveRendererRegistrations } from './assemble' +import { resolve } from 'pathe' +import { DEVFRAMES_HUB_BASE, normalizeHubBase } from '../constants' +import { mountDevframes, resolveDevframesInput } from './assemble' +import { bakeHubStatic } from './bake' import { createHubContext } from './context' -import { diagnostics } from './diagnostics' -export interface BuildHubOptions { +export type { BakeHubStaticOptions } from './bake' +export { bakeHubStatic } from './bake' + +export interface BuildHubOptions extends Omit { /** * Output directory the hub subtree is written into. It corresponds to the * hub {@link BuildHubOptions.base} at serve time: building with @@ -25,12 +21,6 @@ export interface BuildHubOptions { * `dist/` servable as-is by any static file server. */ outDir: string - /** - * Mount base the deployed hub answers under (baked into every absolute URL - * the build emits: page-script rewrites, renderer modules, per-frame meta - * pointers). Default: `/__devframes/`. - */ - base?: string /** Devframes to bake as docks, same input as `initHub({ devframes })`. */ devframes?: DevframesInput /** Host-level wire services, same contract as `initHub({ services })`. */ @@ -50,90 +40,56 @@ export interface BuildHubOptions { * config (branding, dock preferences) is baked into the connection meta. */ ui?: DevframeHubUi - /** Prebuilt dock-renderer modules, copied to `__renderers/.mjs`. */ - renderers?: readonly DockRendererRegistration[] - /** Name written into `__index.json`. */ - name?: string - /** Version written into `__index.json`. */ - version?: string /** Working directory for the hub context. Default: `process.cwd()`. */ cwd?: string /** Override where persisted devframe state lives during the build. */ getStorageDir?: (scope: DevframeStorageScope) => string - /** Pretty-print RPC dump JSON files. Default: `false` (minified shards). */ - pretty?: boolean } /** * Produce a self-contained static deploy of a whole hub, the multi-devframe - * counterpart of devframe's `createBuild`: + * counterpart of devframe's `createBuild`. Composes the pipeline from its two + * public halves: build a `mode: 'build'` hub context and mount every devframe + * ({@link createHubContext} + {@link mountDevframes}), then bake it + * ({@link bakeHubStatic}). A host that assembles its own context (Vite + * DevTools' kit context, devframes mounted from Vite plugins) skips this and + * calls `bakeHubStatic` directly. * - * - Build a `mode: 'build'` hub context and mount every devframe: each - * SPA is copied to `//`, an absolute-path page script to + * - Each SPA is copied to `//`, an absolute-path page script to * `//__page-script/`, and its `setup(ctx)` runs. - * - Copy the UI slot's viewer SPA, `embedded.js`, and the renderer - * modules, and write the discovery documents (`__index.json`, + * - The UI slot's viewer SPA, `embedded.js`, and the renderer modules are + * copied, and the discovery documents written (`__index.json`, * `__client-imports.js`). - * - Write `__connection.json` (`{ backend: 'static' }`, carrying - * `ctx.staticConfig` as `configs`) at the hub base and at every frame - * base (each pointing back at the hub's own meta via `baseUrl`). - * - Bake the shared RPC dump under `/__rpc-dump/`: every - * `static`/`snapshot` RPC plus a snapshot of each shared-state key - * (docks, commands, renderer manifest), so `createDevframeClientRuntime` - * and every frame SPA boot from the dump with no live server. + * - `__connection.json` (`{ backend: 'static' }`, carrying `ctx.staticConfig` + * as `configs`) is written at the hub base and every frame base. + * - The shared RPC dump is baked under `/__rpc-dump/`, so + * `createDevframeClientRuntime` and every frame SPA boot from the dump with + * no live server. * - * Reads work from the baked dump; live writes (messages, terminals, - * commands execution) have no server and degrade to no-ops in the clients. + * Reads work from the baked dump; live writes (messages, terminals, commands + * execution) have no server and degrade to no-ops in the clients. */ export async function buildHub(options: BuildHubOptions): Promise { const base = normalizeHubBase(options.base ?? DEVFRAMES_HUB_BASE) const cwd = options.cwd ?? process.cwd() - const outDir = resolve(cwd, options.outDir) - const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) - - if (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`. */ - function resolveOutPath(urlBase: string): string { - if (!urlBase.startsWith(base)) - throw diagnostics.DF8006({ urlBase, base }) - return resolve(outDir, urlBase.slice(base.length)) - } - - // Every base a devframe's SPA was mounted at, so a per-frame - // `__connection.json` (pointing back at the hub's own meta) is written - // alongside each copied SPA. - const frameMetaBases: string[] = [] + // A build host whose `mountStatic` is a no-op: `bakeHubStatic` copies every + // static from `ctx.views.buildStaticDirs`, so nothing needs to be served + // live during mounting. const h3Host = createH3DevframeHost({ origin: 'http://localhost', appName: 'devframes', workspaceRoot: cwd, - /** - * A static build "serves" by copying: a local dist verbatim, a remote - * assets source by materializing every listed file. - */ - mount: async (mountBase, source) => { - const target = resolveOutPath(mountBase) - await fs.mkdir(dirname(target), { recursive: true }) - if (typeof source === 'string') - await fs.cp(source, target, { recursive: true }) - else - await source.materialize(target) - }, + mount: () => {}, }) const host = { ...h3Host, ...(options.getStorageDir ? { getStorageDir: options.getStorageDir } : {}), - mountConnectionMeta: (frameBase: string) => { - // Validate eagerly (this hook is awaited before the SPA mount, which is - // fire-and-forget), so an out-of-base mount fails the build here rather - // than as an unhandled rejection inside the copy. - resolveOutPath(frameBase) - frameMetaBases.push(frameBase) - }, + /** + * Serving each frame's meta live is unnecessary in a build: `bakeHubStatic` + * writes the per-frame metas from `ctx.frames`. + */ + mountConnectionMeta: () => {}, } const ctx = await createHubContext({ @@ -147,8 +103,7 @@ export async function buildHub(options: BuildHubOptions): Promise { const devframes = await resolveDevframesInput(options.devframes ?? []) for (const input of options.services ?? []) void ctx.services.install(input) - const frames: { id: string, base: string, title: string }[] = [] - const setups = await mountDevframes(ctx, devframes, base, frames, false) + const setups = await mountDevframes(ctx, devframes, base, false) await ctx.services.ready() for (const run of setups) @@ -157,105 +112,14 @@ export async function buildHub(options: BuildHubOptions): Promise { await options.configure?.(ctx) await options.ui?.setup?.(ctx) - 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 fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({ - name: options.name, - version: options.version, + await bakeHubStatic(ctx, { + outDir: resolve(cwd, options.outDir), base, - frames, - endpoints: { - connection: DEVFRAME_CONNECTION_META_FILENAME, - clientImports: DEVFRAME_DOCK_IMPORTS_FILENAME, - index: '__index.json', - ...(options.ui?.embedded ? { embedded: 'embedded.js' } : {}), - }, - }, null, 2)}\n`, 'utf-8') - - await writeConnectionMetas(ctx, base, outDir, frameMetaBases.map(resolveOutPath)) - - console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(outDir, '__rpc-dump')}`) - const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) - await writeStaticRpcDump(dump, outDir, { pretty: options.pretty }) - - console.log(c.green`[devframes-hub] built ${frames.length} devframe${frames.length === 1 ? '' : 's'} -> ${outDir}`) -} - -/** - * Publish the renderer manifest exactly like `initHub` does, so it is baked - * into the shared-state snapshot, and copy each prebuilt module to the URL - * the manifest advertises. - */ -async function publishRendererManifest( - ctx: DevframeHubContext, - registrations: readonly DockRendererRegistration[], - base: string, - outDir: string, -): Promise { - const manifest: Record = {} - 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`)) - } - const manifestState = await ctx.rpc.sharedState.get>( - DOCK_RENDERERS_STATE_KEY, - { initialValue: {} }, - ) - manifestState.mutate(() => manifest) -} - -/** - * Copy the UI slot's artifacts: the viewer SPA owns the hub base (copied - * 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 { - if (ui?.viewer) - await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true }) - if (ui?.embedded) - await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js')) - for (const [key, produce] of Object.entries(ui?.assets ?? {})) { - const target = resolve(outDir, key) - await fs.mkdir(dirname(target), { recursive: true }) - await fs.writeFile(target, produce()) - } -} - -/** - * Write the `backend: 'static'` connection meta at the hub base, and a copy - * at every frame base whose `baseUrl` points relative resolution (the RPC - * dump) back at the hub's own meta, so a frame SPA that fetched its - * per-frame copy (instead of inheriting the host page's connection) still - * finds the shared dump. - */ -async function writeConnectionMetas( - ctx: DevframeHubContext, - base: string, - outDir: string, - frameDirs: readonly string[], -): Promise { - const jsonSerializableMethods: string[] = [] - for (const def of ctx.rpc.definitions.values()) { - if (def.jsonSerializable === true) - jsonSerializableMethods.push(def.name) - } - const meta: ConnectionMeta = { - backend: 'static', - 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') - const frameMeta: ConnectionMeta = { ...meta, baseUrl: joinURL(base, DEVFRAME_CONNECTION_META_FILENAME) } - for (const frameDir of frameDirs) { - const target = resolve(frameDir, DEVFRAME_CONNECTION_META_FILENAME) - await fs.mkdir(dirname(target), { recursive: true }) - await fs.writeFile(target, JSON.stringify(frameMeta, null, 2), 'utf-8') - } + ...(options.ui ? { ui: options.ui } : {}), + ...(options.renderers ? { renderers: options.renderers } : {}), + ...(options.name !== undefined ? { name: options.name } : {}), + ...(options.version !== undefined ? { version: options.version } : {}), + ...(options.pretty !== undefined ? { pretty: options.pretty } : {}), + ...(options.clean !== undefined ? { clean: options.clean } : {}), + }) } diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index e52b5e414..1352e64f9 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -85,6 +85,28 @@ declare module 'devframe/types' { } } +/** + * A devframe mounted into a hub context, recorded as it is installed + * (whether through `initHub({ devframes })`, `buildHub`, or `ctx.install`). + * Enumerable via {@link DevframeHubContext.frames} so a host that mounted the + * context itself can still discover what to advertise in `__index.json` and + * where to write each frame's connection meta. + */ +export interface HubMountedFrame { + /** Dock id the devframe mounted under (disambiguated for duplicates). */ + id: string + /** Hub-base-relative mount base of the frame's SPA (trailing slash). */ + base: string + /** Human title (the definition's `name`). */ + title: string + /** + * Whether the devframe served client assets at {@link HubMountedFrame.base}. + * A frame with its own SPA gets a per-frame `__connection.json` in a static + * build (pointing back at the hub's shared meta); one without does not. + */ + hasClientAssets: boolean +} + /** * Hub-augmented node context that extends devframe's framework-neutral * `DevframeNodeContext` with the hub-level subsystems (`docks`, @@ -104,6 +126,13 @@ export interface DevframeHubContext extends DevframeNodeContext { terminals: DevframeTerminalsHost messages: DevframeMessagesHost commands: DevframeCommandsHost + /** + * Every devframe mounted into this context, in mount order. Populated by + * `ctx.install` (and the batch mount `initHub`/`buildHub` run through it), + * so a host that assembled and mounted the context itself can hand it to + * {@link import('./bake').bakeHubStatic} to emit the discovery documents. + */ + readonly frames: readonly HubMountedFrame[] /** * Install a {@link DevframeDefinition} into this hub: serve its SPA at the * resolved base, synthesize an iframe dock from its metadata, and run its @@ -146,6 +175,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis context.terminals = terminals context.messages = messages context.commands = commands + ;(context as { frames: readonly HubMountedFrame[] }).frames = [] context.install = (devframe, options) => installDevframe(context, devframe, options) await docks.init() diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index f0dbbe05f..0a73f3191 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -391,7 +391,6 @@ export function initHub(options: InitHubOptions): HubInstance { const baseNoSlash = base.slice(0, -1) const app = new H3() const cwd = options.cwd ?? process.cwd() - const frames: { id: string, base: string, title: string }[] = [] const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) const shell = createInstanceShell({ @@ -443,7 +442,7 @@ export function initHub(options: InitHubOptions): HubInstance { // collection alongside every devframe's own declared services. for (const input of options.services ?? []) void ctx.services.install(input) - const setups = await mountDevframes(ctx, devframes, base, frames, options.mcp !== false) + const setups = await mountDevframes(ctx, devframes, base, options.mcp !== false) // Construct every collected service once, then run the setups, so a // devframe's setup consumes services (its own or another devframe's) @@ -521,7 +520,7 @@ export function initHub(options: InitHubOptions): HubInstance { name: options.name, version: options.version, base, - frames, + frames: ctx.frames.map(({ id, base, title }) => ({ id, base, title })), endpoints: { connection: DEVFRAME_CONNECTION_META_FILENAME, clientImports: DEVFRAME_DOCK_IMPORTS_FILENAME, diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index bdc59d71f..281cf7961 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -1,6 +1,6 @@ import type { DevframeDefinition } from 'devframe/types' import type { ClientScriptEntry, DevframeViewIframe } from '../types/docks' -import type { DevframeHubContext } from './context' +import type { DevframeHubContext, HubMountedFrame } from './context' import { existsSync } from 'node:fs' import { resolveClientAssets } from 'devframe/internal' import { resolveBasePath } from 'devframe/node/hub-internals' @@ -55,7 +55,10 @@ async function resolvePageScriptClientScript( if (!isAbsolute(importFrom) || !existsSync(importFrom)) return clientScript const scriptBase = withTrailingSlash(joinURL(base, '__page-script')) - await ctx.host.mountStatic(scriptBase, dirname(importFrom)) + // Route through `views.hostStatic` (not the bare `host.mountStatic`) so the + // directory lands in `ctx.views.buildStaticDirs`, and a static build bakes + // it whether it copies statics live during mount or from that list. + ctx.views.hostStatic(scriptBase, dirname(importFrom)) return { ...clientScript, importFrom: joinURL(scriptBase, basename(importFrom)) } } @@ -91,10 +94,10 @@ async function serveDevframeAssets( d: DevframeDefinition, id: string, base: string, -): Promise { +): Promise { const clientAssets = resolveClientAssets(d) if (!clientAssets) - return + return false // Serve the hub's connection meta under the devframe's base so its SPA // discovers the RPC/WS endpoint via `connectDevframe()`'s relative // `./__connection.json` fetch (rather than inheriting cross-origin from a @@ -107,6 +110,7 @@ async function serveDevframeAssets( // Resolve the plugin's assets against *its* dependency graph: pass the // devframe's own `importMetaUrl` as the default `resolveFrom`. ctx.views.hostStatic(base, typeof clientAssets === 'string' ? resolve(clientAssets) : clientAssets, d.importMetaUrl) + return true } /** @@ -114,7 +118,7 @@ async function serveDevframeAssets( * false` declares its value inherently live (a terminal, a process proxy), * so `buildHub` never mounts it, registers its dock, or bakes its RPCs. */ -export function skippedInStaticBuild(ctx: DevframeHubContext, d: DevframeDefinition): boolean { +function skippedInStaticBuild(ctx: DevframeHubContext, d: DevframeDefinition): boolean { return ctx.mode === 'build' && d.capabilities?.build === false } @@ -155,7 +159,9 @@ export async function prepareDevframe( if (clientScript) dockDefaults.clientScript = clientScript - await serveDevframeAssets(ctx, d, id, base) + const hasClientAssets = await serveDevframeAssets(ctx, d, id, base) + + ;(ctx.frames as HubMountedFrame[]).push({ id, base, title: d.name, hasClientAssets }) ctx.docks.register({ id, From c3a954f32dfbe9da5cf8015397231f660af78d56 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 3 Sep 2026 13:08:30 +0000 Subject: [PATCH 2/5] refactor(hub): minimize bakeHubStatic change surface Ablation of the previous commit dropped two deltas that no tested or in-repo path depends on: - Removed the `resolveFrom` field added to devframe's core `buildStaticDirs` (types/views + host-views). No devframe in the repo declares RemoteAssets client assets, and string dist dirs ignore resolveFrom, so re-resolving in bakeHubStatic without it is equivalent. Confines the change to @devframes/hub. - Removed `HubMountedFrame.hasClientAssets` (and serveDevframeAssets' boolean return): whether a frame served its own SPA is derivable from ctx.views.buildStaticDirs, so bakeHubStatic derives the per-frame meta targets instead of carrying an extra field on the public type. --- packages/devframe/src/node/host-views.ts | 4 ++-- packages/devframe/src/types/views.ts | 8 +------- .../src/node/__tests__/install-devframe.test.ts | 1 - packages/hub/src/node/bake.ts | 17 ++++++++++------- packages/hub/src/node/context.ts | 9 +-------- packages/hub/src/node/install-devframe.ts | 9 ++++----- 6 files changed, 18 insertions(+), 30 deletions(-) diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index bfa668fc7..d7e18d123 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -7,7 +7,7 @@ export class DevframeViewHost implements DevframeViewHostType { /** * @internal */ - public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] = [] + public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] = [] constructor( public readonly context: DevframeNodeContext, @@ -30,7 +30,7 @@ export class DevframeViewHost implements DevframeViewHostType { throw diagnostics.DF0008({ distDir: resolved }) } - this.buildStaticDirs.push({ baseUrl, source, resolveFrom: defaultResolveFrom }) + this.buildStaticDirs.push({ baseUrl, source }) this.context.host.mountStatic(baseUrl, resolved) } } diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index 9e8dc2384..dd5c03839 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -2,15 +2,9 @@ import type { StaticAssetsSource } from './remote-assets' export interface DevframeViewHost { /** - * Static mounts registered through {@link DevframeViewHost.hostStatic}, in - * registration order, each carrying the `resolveFrom` base it was mounted - * with so a build step can re-resolve a remote source identically. A static - * build that assembles the context itself (rather than serving it live) - * copies these into its output. - * * @internal */ - buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] + buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] /** * Helper to host static files * - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts index 5c200d7d0..e48188e4a 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -28,7 +28,6 @@ function createContext(): DevframeHubContext { hostStatic: vi.fn((baseUrl: string, source: unknown) => { mountStatic(baseUrl, source as string) }), - buildStaticDirs: [], }, frames: [], /** diff --git a/packages/hub/src/node/bake.ts b/packages/hub/src/node/bake.ts index 28cc9d1ad..f849a8c12 100644 --- a/packages/hub/src/node/bake.ts +++ b/packages/hub/src/node/bake.ts @@ -110,9 +110,9 @@ export async function bakeHubStatic(ctx: DevframeHubContext, options: BakeHubSta */ async function copyBuildStatics(ctx: DevframeHubContext, resolveOutPath: (urlBase: string) => string): Promise { const storageDir = ctx.host.getStorageDir('project') - for (const { baseUrl, source, resolveFrom } of ctx.views.buildStaticDirs) { + for (const { baseUrl, source } of ctx.views.buildStaticDirs) { const target = resolveOutPath(baseUrl) - const resolved = resolveStaticAssetsSource(source, storageDir, resolveFrom) + const resolved = resolveStaticAssetsSource(source, storageDir) await fs.mkdir(dirname(target), { recursive: true }) if (typeof resolved === 'string') await fs.cp(resolved, target, { recursive: true }) @@ -188,10 +188,10 @@ async function writeHubIndex( /** * Write the `backend: 'static'` connection meta at the hub base, and a copy at - * every frame base that served its own SPA whose `baseUrl` points relative - * resolution (the RPC dump) back at the hub's own meta, so a frame SPA that - * fetched its per-frame copy (instead of inheriting the host page's connection) - * still finds the shared dump. + * every frame base that served its own SPA (i.e. registered a static mount at + * that base) whose `baseUrl` points relative resolution (the RPC dump) back at + * the hub's own meta, so a frame SPA that fetched its per-frame copy (instead + * of inheriting the host page's connection) still finds the shared dump. */ async function writeConnectionMetas( ctx: DevframeHubContext, @@ -211,8 +211,11 @@ async function writeConnectionMetas( } await fs.writeFile(resolve(outDir, 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. + const servedBases = new Set(ctx.views.buildStaticDirs.map(dir => dir.baseUrl)) for (const frame of ctx.frames) { - if (!frame.hasClientAssets) + if (!servedBases.has(frame.base)) continue const target = resolve(resolveOutPath(frame.base), DEVFRAME_CONNECTION_META_FILENAME) await fs.mkdir(dirname(target), { recursive: true }) diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 1352e64f9..7af03acfb 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -89,8 +89,7 @@ declare module 'devframe/types' { * A devframe mounted into a hub context, recorded as it is installed * (whether through `initHub({ devframes })`, `buildHub`, or `ctx.install`). * Enumerable via {@link DevframeHubContext.frames} so a host that mounted the - * context itself can still discover what to advertise in `__index.json` and - * where to write each frame's connection meta. + * context itself can still discover what to advertise in `__index.json`. */ export interface HubMountedFrame { /** Dock id the devframe mounted under (disambiguated for duplicates). */ @@ -99,12 +98,6 @@ export interface HubMountedFrame { base: string /** Human title (the definition's `name`). */ title: string - /** - * Whether the devframe served client assets at {@link HubMountedFrame.base}. - * A frame with its own SPA gets a per-frame `__connection.json` in a static - * build (pointing back at the hub's shared meta); one without does not. - */ - hasClientAssets: boolean } /** diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 281cf7961..90e836ceb 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -94,10 +94,10 @@ async function serveDevframeAssets( d: DevframeDefinition, id: string, base: string, -): Promise { +): Promise { const clientAssets = resolveClientAssets(d) if (!clientAssets) - return false + return // Serve the hub's connection meta under the devframe's base so its SPA // discovers the RPC/WS endpoint via `connectDevframe()`'s relative // `./__connection.json` fetch (rather than inheriting cross-origin from a @@ -110,7 +110,6 @@ async function serveDevframeAssets( // Resolve the plugin's assets against *its* dependency graph: pass the // devframe's own `importMetaUrl` as the default `resolveFrom`. ctx.views.hostStatic(base, typeof clientAssets === 'string' ? resolve(clientAssets) : clientAssets, d.importMetaUrl) - return true } /** @@ -159,9 +158,9 @@ export async function prepareDevframe( if (clientScript) dockDefaults.clientScript = clientScript - const hasClientAssets = await serveDevframeAssets(ctx, d, id, base) + await serveDevframeAssets(ctx, d, id, base) - ;(ctx.frames as HubMountedFrame[]).push({ id, base, title: d.name, hasClientAssets }) + ;(ctx.frames as HubMountedFrame[]).push({ id, base, title: d.name }) ctx.docks.register({ id, From 0aaabab1b88ad9c2ea59ee050263ca8ab72795b1 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 4 Sep 2026 01:31:47 +0000 Subject: [PATCH 3/5] refactor(hub): reuse buildHub({ context }) instead of a bakeHubStatic concept Rather than introduce a new "bake" verb/function, mirror initHub's existing `context` option: buildHub now accepts an already-mounted DevframeHubContext and bakes it (reading ctx.frames + ctx.views.buildStaticDirs), throwing DF8002 if both `context` and `devframes` are passed. The baking helpers fold back into build.ts as module-private functions; the public surface stays just buildHub. --- docs/content/1.guide/18.hub-initiate.md | 2 +- docs/content/6.errors/DF8002.md | 9 +- docs/content/6.errors/DF8006.md | 4 +- docs/content/8.references/6.hub-api.md | 5 +- packages/hub/src/node/__tests__/build.test.ts | 15 +- packages/hub/src/node/bake.ts | 224 -------------- packages/hub/src/node/build.ts | 277 ++++++++++++++---- packages/hub/src/node/context.ts | 2 +- packages/hub/src/node/diagnostics.ts | 4 +- 9 files changed, 246 insertions(+), 296 deletions(-) delete mode 100644 packages/hub/src/node/bake.ts diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 7b30ad91f..7eca5d28d 100644 --- a/docs/content/1.guide/18.hub-initiate.md +++ b/docs/content/1.guide/18.hub-initiate.md @@ -127,4 +127,4 @@ const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx }) It then serves only hub-level endpoints and transport; serve each mounted devframe's meta from `hub.connectionMeta()` yourself. -The same context works for a static build: `bakeHubStatic(ctx, { outDir, base })` from `@devframes/hub/build` bakes an already-mounted context (`buildHub` is `createHubContext` + `mountDevframes` + `bakeHubStatic`). It reads `ctx.views.buildStaticDirs` for the statics to copy and `ctx.frames` for the frames to advertise, so a host that mounted its own context reuses the exact baker rather than reimplementing it. Pass `clean: false` to bake beside an app's own build output. +The same `context` option works for a static build: `buildHub({ context: ctx, outDir })` bakes an already-mounted context instead of a `devframes` list, reading `ctx.frames` and `ctx.views.buildStaticDirs` for what to emit, so a host that mounted its own context reuses `buildHub` rather than reimplementing it. Pass `clean: false` to bake beside an app's own build output. diff --git a/docs/content/6.errors/DF8002.md b/docs/content/6.errors/DF8002.md index 905afaa54..79be99e55 100644 --- a/docs/content/6.errors/DF8002.md +++ b/docs/content/6.errors/DF8002.md @@ -1,15 +1,15 @@ --- -title: 'DF8002: Both devframes and context Passed to initHub' -description: 'initHub received both devframes and context; the two assembly modes are mutually exclusive.' +title: 'DF8002: Both devframes and context Passed to initHub/buildHub' +description: 'initHub/buildHub received both devframes and context; the two assembly modes are mutually exclusive.' --- ## Message -> initHub received both `devframes` and `context`; the two assembly modes are mutually exclusive. +> `initHub`/`buildHub` received both `devframes` and `context`; the two assembly modes are mutually exclusive. ## Cause -`initHub` assembles a hub two ways: **declaratively** (`devframes: [...]`, where the instance creates the hub context and mounts each devframe under `/`), or **from a pre-built context** (`context: ctx`, where your host framework already mounted the devframes and the instance serves only the hub-level endpoints and transport). A `devframes` list cannot be mounted into a context the instance doesn't own, so passing both contradicts. +`initHub` (and `buildHub`) assembles a hub two ways: **declaratively** (`devframes: [...]`, where it creates the hub context and mounts each devframe under `/`), or **from a pre-built context** (`context: ctx`, where your host framework already mounted the devframes). A `devframes` list cannot be mounted into a context it doesn't own, so passing both contradicts. ## Example @@ -33,3 +33,4 @@ Pick one mode. Use `configure(ctx)` on the declarative mode when you need post-m ## Source - [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts): `initHub` throws this during initialization when both options are present. +- [`packages/hub/src/node/build.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/build.ts): `buildHub` throws this when both options are present. diff --git a/docs/content/6.errors/DF8006.md b/docs/content/6.errors/DF8006.md index dc576b41c..2b964be16 100644 --- a/docs/content/6.errors/DF8006.md +++ b/docs/content/6.errors/DF8006.md @@ -9,7 +9,7 @@ description: 'A static hub build can only write mounts under its own base: "{url ## Cause -`bakeHubStatic` (which `buildHub` runs) 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` (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`. ## Example @@ -30,4 +30,4 @@ await buildHub({ ## Source -- [`packages/hub/src/node/bake.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/bake.ts): `bakeHubStatic()`'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 hub base. diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 902533e66..d12649199 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -92,13 +92,10 @@ The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/ |---|---| | `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). | | `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. | | `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). | -## `bakeHubStatic` options - -`bakeHubStatic(ctx, options)` from `@devframes/hub/build` is the second half of `buildHub`: it bakes an already-mounted `DevframeHubContext` a caller assembled itself (`createHubContext` + `ctx.install`, or a framework kit's own context), reading `ctx.views.buildStaticDirs` for the statics to copy and `ctx.frames` for the frames to advertise. `buildHub` is `createHubContext` + `mountDevframes` + `bakeHubStatic`. Options: `outDir`, `base`, `ui`, `renderers`, `name`, `version`, `pretty`, and `clean`, same contracts as their `buildHub` counterparts. - ## Client runtime options The options of `createDevframeClientRuntime()`: [The client runtime](/guide/client-context#the-client-runtime). diff --git a/packages/hub/src/node/__tests__/build.test.ts b/packages/hub/src/node/__tests__/build.test.ts index 797f337f7..3be80ef65 100644 --- a/packages/hub/src/node/__tests__/build.test.ts +++ b/packages/hub/src/node/__tests__/build.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path' import { createH3DevframeHost } from 'devframe/internal' import { describe, expect, it } from 'vitest' import { HUB_EVENTS } from '../../events' -import { bakeHubStatic } from '../bake' import { buildHub } from '../build' import { createHubContext } from '../context' @@ -139,22 +138,22 @@ describe('buildHub', () => { expect(readFileSync(join(outDir, 'beta/index.html'), 'utf-8')).toContain('beta') }) - it('bakes an externally-mounted context via bakeHubStatic', async () => { - const outDir = join(mkdtempSync(join(tmpdir(), 'hub-bake-out-')), 'hub') - const cwd = mkdtempSync(join(tmpdir(), 'hub-bake-cwd-')) + it('bakes an externally-mounted context passed as `context`', async () => { + const outDir = join(mkdtempSync(join(tmpdir(), 'hub-ctx-out-')), 'hub') + const cwd = mkdtempSync(join(tmpdir(), 'hub-ctx-cwd-')) // A host assembling the context itself: create + mount via `ctx.install`, - // then hand the already-mounted context to the baker. + // then hand the already-mounted context to `buildHub`. 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('alpha', { distDir: makeDist('

alpha

') }), { base: '/__hub/alpha/' }) expect(ctx.frames.map(frame => frame.id)).toEqual(['alpha']) - await bakeHubStatic(ctx, { outDir, base: '/__hub/' }) + await buildHub({ context: ctx, outDir, base: '/__hub/' }) - // The baker copied the SPA from `ctx.views.buildStaticDirs`, wrote the - // index from `ctx.frames`, and emitted the per-frame meta + shared dump. + // The SPA was copied from `ctx.views.buildStaticDirs`, the index written + // from `ctx.frames`, and the per-frame meta + shared dump emitted. expect(readFileSync(join(outDir, 'alpha/index.html'), 'utf-8')).toContain('alpha') const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8')) expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['alpha']) diff --git a/packages/hub/src/node/bake.ts b/packages/hub/src/node/bake.ts deleted file mode 100644 index f849a8c12..000000000 --- a/packages/hub/src/node/bake.ts +++ /dev/null @@ -1,224 +0,0 @@ -/* eslint-disable no-console */ -import type { ConnectionMeta } from 'devframe/types' -import type { ClientScriptEntry } from '../types/docks' -import type { DevframeHubContext } from './context' -import type { DevframeHubUi, DockRendererRegistration } from './initiate' -import { existsSync } from 'node:fs' -import fs from 'node:fs/promises' -import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME } from 'devframe/constants' -import { collectStaticRpcDump, writeStaticRpcDump } from 'devframe/rpc/dump' -import { colors as c } from 'devframe/utils/colors' -import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' -import { dirname, resolve } from 'pathe' -import { joinURL } from 'ufo' -import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants' -import { renderClientImportsModule, resolveRendererRegistrations } from './assemble' -import { diagnostics } from './diagnostics' - -export interface BakeHubStaticOptions { - /** - * Output directory the hub subtree is written into, the on-disk counterpart - * of {@link BakeHubStaticOptions.base}. - */ - outDir: string - /** - * Mount base the deployed hub answers under, baked into every absolute URL - * the build emits. Default: `/__devframes/`. - */ - base?: string - /** - * The hub's UI slot: its viewer SPA is copied to the hub base, `embedded.js` - * next to it, and any produced assets written. `setup(ctx)` is the caller's - * responsibility (it must run before baking so its static config is in the - * connection meta). - */ - ui?: DevframeHubUi - /** Prebuilt dock-renderer modules, copied to `__renderers/.mjs`. */ - renderers?: readonly DockRendererRegistration[] - /** Name written into `__index.json`. */ - name?: string - /** Version written into `__index.json`. */ - version?: string - /** Pretty-print RPC dump JSON files. Default: `false` (minified shards). */ - pretty?: boolean - /** - * Remove {@link BakeHubStaticOptions.outDir} before writing. Default `true`, - * matching a from-scratch build. Pass `false` to bake into a directory that - * already holds sibling output (an app's own `dist/` the hub subtree lives - * beside). - */ - clean?: boolean -} - -/** - * Bake an already-mounted hub context into a self-contained static deploy: the - * tail half of `buildHub` factored out so a host that assembled and mounted the - * context itself (Vite DevTools' kit-augmented context, a devframe mounted from - * a Vite plugin's `devtools.setup`) reuses the exact baker instead of - * reimplementing it. - * - * The caller owns context creation, devframe mounting, `configure`, and the UI - * slot's `setup`; this reads what they left behind: - * - * - copies every static registered through `ctx.views.hostStatic` (frame - * SPAs, page scripts, hub statics) from `ctx.views.buildStaticDirs`; - * - publishes the renderer manifest and copies the renderer modules; - * - copies the UI slot's viewer SPA, `embedded.js`, and produced assets; - * - writes the discovery documents (`__client-imports.js`, `__index.json` - * from `ctx.frames`); - * - writes `__connection.json` (`{ backend: 'static' }`) at the hub base and - * at every frame base that served its own SPA; - * - bakes the shared RPC dump under `/__rpc-dump/`. - */ -export async function bakeHubStatic(ctx: DevframeHubContext, options: BakeHubStaticOptions): Promise { - const base = normalizeHubBase(options.base ?? DEVFRAMES_HUB_BASE) - const outDir = resolve(options.outDir) - const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) - - 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`. */ - const resolveOutPath = (urlBase: string): string => { - if (!urlBase.startsWith(base)) - throw diagnostics.DF8006({ urlBase, base }) - return resolve(outDir, urlBase.slice(base.length)) - } - - 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) - - console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(outDir, '__rpc-dump')}`) - const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) - await writeStaticRpcDump(dump, outDir, { pretty: options.pretty }) - - const count = ctx.frames.length - console.log(c.green`[devframes-hub] built ${count} devframe${count === 1 ? '' : 's'} -> ${outDir}`) -} - -/** - * Copy every static the context registered through `ctx.views.hostStatic` - * (recorded in `ctx.views.buildStaticDirs`): a local dir verbatim, a remote - * source by materializing every listed file. Reads the list rather than - * relying on a live host `mountStatic`, so a context whose host baked no - * statics at mount time (a kit build host) still gets its assets copied. - */ -async function copyBuildStatics(ctx: DevframeHubContext, resolveOutPath: (urlBase: string) => string): Promise { - const storageDir = ctx.host.getStorageDir('project') - for (const { baseUrl, source } of ctx.views.buildStaticDirs) { - const target = resolveOutPath(baseUrl) - const resolved = resolveStaticAssetsSource(source, storageDir) - await fs.mkdir(dirname(target), { recursive: true }) - if (typeof resolved === 'string') - await fs.cp(resolved, target, { recursive: true }) - else - await resolved.materialize(target) - } -} - -/** - * Publish the renderer manifest exactly like `initHub` does, so it is baked - * into the shared-state snapshot, and copy each prebuilt module to the URL - * the manifest advertises. - */ -async function publishRendererManifest( - ctx: DevframeHubContext, - registrations: readonly DockRendererRegistration[], - base: string, - outDir: string, -): Promise { - const manifest: Record = {} - 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`)) - } - const manifestState = await ctx.rpc.sharedState.get>( - DOCK_RENDERERS_STATE_KEY, - { initialValue: {} }, - ) - manifestState.mutate(() => manifest) -} - -/** - * Copy the UI slot's artifacts: the viewer SPA owns the hub base (copied - * 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 { - if (ui?.viewer) - await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true }) - if (ui?.embedded) - await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js')) - for (const [key, produce] of Object.entries(ui?.assets ?? {})) { - const target = resolve(outDir, key) - await fs.mkdir(dirname(target), { recursive: true }) - await fs.writeFile(target, produce()) - } -} - -/** Write `__index.json`: the discovery document listing every frame. */ -async function writeHubIndex( - ctx: DevframeHubContext, - base: string, - outDir: string, - options: BakeHubStaticOptions, -): Promise { - await fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({ - name: options.name, - version: options.version, - base, - frames: ctx.frames.map(({ id, base, title }) => ({ id, base, title })), - endpoints: { - connection: DEVFRAME_CONNECTION_META_FILENAME, - clientImports: DEVFRAME_DOCK_IMPORTS_FILENAME, - index: '__index.json', - ...(options.ui?.embedded ? { embedded: 'embedded.js' } : {}), - }, - }, null, 2)}\n`, 'utf-8') -} - -/** - * Write the `backend: 'static'` connection meta at the hub base, and a copy at - * every frame base that served its own SPA (i.e. registered a static mount at - * that base) whose `baseUrl` points relative resolution (the RPC dump) back at - * the hub's own meta, so a frame SPA that fetched its per-frame copy (instead - * of inheriting the host page's connection) still finds the shared dump. - */ -async function writeConnectionMetas( - ctx: DevframeHubContext, - base: string, - outDir: string, - resolveOutPath: (urlBase: string) => string, -): Promise { - const jsonSerializableMethods: string[] = [] - for (const def of ctx.rpc.definitions.values()) { - if (def.jsonSerializable === true) - jsonSerializableMethods.push(def.name) - } - const meta: ConnectionMeta = { - backend: 'static', - 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') - 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. - const servedBases = new Set(ctx.views.buildStaticDirs.map(dir => dir.baseUrl)) - for (const frame of ctx.frames) { - if (!servedBases.has(frame.base)) - continue - const target = resolve(resolveOutPath(frame.base), DEVFRAME_CONNECTION_META_FILENAME) - await fs.mkdir(dirname(target), { recursive: true }) - await fs.writeFile(target, JSON.stringify(frameMeta, null, 2), 'utf-8') - } -} diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index 561117b75..9c9fe2101 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -1,19 +1,24 @@ -import type { DevframeServiceInput, DevframeStorageScope } from 'devframe/types' -import type { BakeHubStaticOptions } from './bake' +/* eslint-disable no-console */ +import type { ConnectionMeta, DevframeServiceInput, DevframeStorageScope } from 'devframe/types' +import type { ClientScriptEntry } from '../types/docks' import type { CreateHubContextOptions, DevframeHubContext } from './context' -import type { DevframeHubUi, DevframesInput } from './initiate' +import type { DevframeHubUi, DevframesInput, DockRendererRegistration } from './initiate' +import { existsSync } from 'node:fs' +import fs from 'node:fs/promises' import process from 'node:process' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME } from 'devframe/constants' import { createH3DevframeHost } from 'devframe/internal' -import { resolve } from 'pathe' -import { DEVFRAMES_HUB_BASE, normalizeHubBase } from '../constants' -import { mountDevframes, resolveDevframesInput } from './assemble' -import { bakeHubStatic } from './bake' +import { collectStaticRpcDump, writeStaticRpcDump } from 'devframe/rpc/dump' +import { colors as c } from 'devframe/utils/colors' +import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' +import { dirname, resolve } from 'pathe' +import { joinURL } from 'ufo' +import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants' +import { mountDevframes, renderClientImportsModule, resolveDevframesInput, resolveRendererRegistrations } from './assemble' import { createHubContext } from './context' +import { diagnostics } from './diagnostics' -export type { BakeHubStaticOptions } from './bake' -export { bakeHubStatic } from './bake' - -export interface BuildHubOptions extends Omit { +export interface BuildHubOptions { /** * Output directory the hub subtree is written into. It corresponds to the * hub {@link BuildHubOptions.base} at serve time: building with @@ -21,8 +26,25 @@ export interface BuildHubOptions extends Omit { * `dist/` servable as-is by any static file server. */ outDir: string + /** + * Mount base the deployed hub answers under (baked into every absolute URL + * the build emits: page-script rewrites, renderer modules, per-frame meta + * pointers). Default: `/__devframes/`. + */ + base?: string /** Devframes to bake as docks, same input as `initHub({ devframes })`. */ devframes?: DevframesInput + /** + * Bring your own already-mounted hub context instead of `devframes`, the + * build counterpart of `initHub({ context })`: a host that assembled + * `createHubContext` + `ctx.install` itself (Vite DevTools' kit-augmented + * context, devframes mounted from Vite plugins) hands the mounted context + * here and `buildHub` bakes it, reading `ctx.frames` and + * `ctx.views.buildStaticDirs` for what to emit. `configure` and the UI + * slot's `setup` still run against it, so pass them here rather than running + * them yourself. Mutually exclusive with `devframes`. + */ + context?: DevframeHubContext /** Host-level wire services, same contract as `initHub({ services })`. */ services?: DevframeServiceInput[] /** Extra RPC declarations registered at context creation. */ @@ -40,55 +62,100 @@ export interface BuildHubOptions extends Omit { * config (branding, dock preferences) is baked into the connection meta. */ ui?: DevframeHubUi + /** Prebuilt dock-renderer modules, copied to `__renderers/.mjs`. */ + renderers?: readonly DockRendererRegistration[] + /** Name written into `__index.json`. */ + name?: string + /** Version written into `__index.json`. */ + version?: string /** Working directory for the hub context. Default: `process.cwd()`. */ cwd?: string /** Override where persisted devframe state lives during the build. */ getStorageDir?: (scope: DevframeStorageScope) => string + /** Pretty-print RPC dump JSON files. Default: `false` (minified shards). */ + pretty?: boolean + /** + * Remove {@link BuildHubOptions.outDir} before writing. Default `true`, + * matching a from-scratch build. Pass `false` to bake into a directory that + * already holds sibling output (an app's own `dist/` the hub subtree lives + * beside). + */ + clean?: boolean } /** * Produce a self-contained static deploy of a whole hub, the multi-devframe - * counterpart of devframe's `createBuild`. Composes the pipeline from its two - * public halves: build a `mode: 'build'` hub context and mount every devframe - * ({@link createHubContext} + {@link mountDevframes}), then bake it - * ({@link bakeHubStatic}). A host that assembles its own context (Vite - * DevTools' kit context, devframes mounted from Vite plugins) skips this and - * calls `bakeHubStatic` directly. + * counterpart of devframe's `createBuild`: * - * - Each SPA is copied to `//`, an absolute-path page script to - * `//__page-script/`, and its `setup(ctx)` runs. - * - The UI slot's viewer SPA, `embedded.js`, and the renderer modules are - * copied, and the discovery documents written (`__index.json`, + * - Build a `mode: 'build'` hub context and mount every devframe (or reuse a + * `context` a host mounted itself): each SPA is copied to `//`, + * an absolute-path page script to `//__page-script/`, and its + * `setup(ctx)` runs. + * - Copy the UI slot's viewer SPA, `embedded.js`, and the renderer + * modules, and write the discovery documents (`__index.json`, * `__client-imports.js`). - * - `__connection.json` (`{ backend: 'static' }`, carrying `ctx.staticConfig` - * as `configs`) is written at the hub base and every frame base. - * - The shared RPC dump is baked under `/__rpc-dump/`, so - * `createDevframeClientRuntime` and every frame SPA boot from the dump with - * no live server. + * - Write `__connection.json` (`{ backend: 'static' }`, carrying + * `ctx.staticConfig` as `configs`) at the hub base and at every frame + * base (each pointing back at the hub's own meta via `baseUrl`). + * - Bake the shared RPC dump under `/__rpc-dump/`: every + * `static`/`snapshot` RPC plus a snapshot of each shared-state key + * (docks, commands, renderer manifest), so `createDevframeClientRuntime` + * and every frame SPA boot from the dump with no live server. * * Reads work from the baked dump; live writes (messages, terminals, commands * execution) have no server and degrade to no-ops in the clients. */ export async function buildHub(options: BuildHubOptions): Promise { + if (options.context && options.devframes?.length) + throw diagnostics.DF8002() + const base = normalizeHubBase(options.base ?? DEVFRAMES_HUB_BASE) const cwd = options.cwd ?? process.cwd() + const outDir = resolve(cwd, options.outDir) + const rendererRegistrations = resolveRendererRegistrations(options.renderers ?? []) - // A build host whose `mountStatic` is a no-op: `bakeHubStatic` copies every - // static from `ctx.views.buildStaticDirs`, so nothing needs to be served - // live during mounting. - const h3Host = createH3DevframeHost({ - origin: 'http://localhost', - appName: 'devframes', - workspaceRoot: cwd, - mount: () => {}, - }) + const ctx = options.context ?? await createAndMountContext(options, base, cwd) + + 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`. */ + const resolveOutPath = (urlBase: string): string => { + if (!urlBase.startsWith(base)) + throw diagnostics.DF8006({ urlBase, base }) + return resolve(outDir, urlBase.slice(base.length)) + } + + 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) + + console.log(c.cyan`[devframes-hub] writing RPC dump to ${resolve(outDir, '__rpc-dump')}`) + const dump = await collectStaticRpcDump(ctx.rpc.definitions.values(), ctx) + await writeStaticRpcDump(dump, outDir, { pretty: options.pretty }) + + const count = ctx.frames.length + console.log(c.green`[devframes-hub] built ${count} devframe${count === 1 ? '' : 's'} -> ${outDir}`) +} + +/** + * Build a `mode: 'build'` hub context and mount the `devframes` input. The host + * copies nothing live (`mountStatic`/`mountConnectionMeta` are no-ops): every + * static is copied afterwards from `ctx.views.buildStaticDirs`, and each frame's + * meta is written from `ctx.frames`. + */ +async function createAndMountContext(options: BuildHubOptions, base: string, cwd: string): Promise { + const h3Host = createH3DevframeHost({ origin: 'http://localhost', appName: 'devframes', workspaceRoot: cwd, mount: () => {} }) const host = { ...h3Host, ...(options.getStorageDir ? { getStorageDir: options.getStorageDir } : {}), - /** - * Serving each frame's meta live is unnecessary in a build: `bakeHubStatic` - * writes the per-frame metas from `ctx.frames`. - */ mountConnectionMeta: () => {}, } @@ -109,17 +176,127 @@ export async function buildHub(options: BuildHubOptions): Promise { for (const run of setups) await run() - await options.configure?.(ctx) - await options.ui?.setup?.(ctx) + return ctx +} + +/** + * Copy every static the context registered through `ctx.views.hostStatic` + * (recorded in `ctx.views.buildStaticDirs`): a local dir verbatim, a remote + * source by materializing every listed file. Reads the list rather than + * relying on a live host `mountStatic`, so a context whose host copied no + * statics at mount time (the build host, or a kit's) still gets its assets in. + */ +async function copyBuildStatics(ctx: DevframeHubContext, resolveOutPath: (urlBase: string) => string): Promise { + const storageDir = ctx.host.getStorageDir('project') + for (const { baseUrl, source } of ctx.views.buildStaticDirs) { + const target = resolveOutPath(baseUrl) + const resolved = resolveStaticAssetsSource(source, storageDir) + await fs.mkdir(dirname(target), { recursive: true }) + if (typeof resolved === 'string') + await fs.cp(resolved, target, { recursive: true }) + else + await resolved.materialize(target) + } +} + +/** + * Publish the renderer manifest exactly like `initHub` does, so it is baked + * into the shared-state snapshot, and copy each prebuilt module to the URL + * the manifest advertises. + */ +async function publishRendererManifest( + ctx: DevframeHubContext, + registrations: readonly DockRendererRegistration[], + base: string, + outDir: string, +): Promise { + const manifest: Record = {} + 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`)) + } + const manifestState = await ctx.rpc.sharedState.get>( + DOCK_RENDERERS_STATE_KEY, + { initialValue: {} }, + ) + manifestState.mutate(() => manifest) +} + +/** + * Copy the UI slot's artifacts: the viewer SPA owns the hub base (copied + * 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 { + if (ui?.viewer) + await fs.cp(resolve(ui.viewer.distDir), outDir, { recursive: true }) + if (ui?.embedded) + await fs.copyFile(resolve(ui.embedded.entry), resolve(outDir, 'embedded.js')) + for (const [key, produce] of Object.entries(ui?.assets ?? {})) { + const target = resolve(outDir, key) + await fs.mkdir(dirname(target), { recursive: true }) + await fs.writeFile(target, produce()) + } +} - await bakeHubStatic(ctx, { - outDir: resolve(cwd, options.outDir), +/** Write `__index.json`: the discovery document listing every frame. */ +async function writeHubIndex( + ctx: DevframeHubContext, + base: string, + outDir: string, + options: BuildHubOptions, +): Promise { + await fs.writeFile(resolve(outDir, '__index.json'), `${JSON.stringify({ + name: options.name, + version: options.version, base, - ...(options.ui ? { ui: options.ui } : {}), - ...(options.renderers ? { renderers: options.renderers } : {}), - ...(options.name !== undefined ? { name: options.name } : {}), - ...(options.version !== undefined ? { version: options.version } : {}), - ...(options.pretty !== undefined ? { pretty: options.pretty } : {}), - ...(options.clean !== undefined ? { clean: options.clean } : {}), - }) + frames: ctx.frames.map(({ id, base, title }) => ({ id, base, title })), + endpoints: { + connection: DEVFRAME_CONNECTION_META_FILENAME, + clientImports: DEVFRAME_DOCK_IMPORTS_FILENAME, + index: '__index.json', + ...(options.ui?.embedded ? { embedded: 'embedded.js' } : {}), + }, + }, null, 2)}\n`, 'utf-8') +} + +/** + * Write the `backend: 'static'` connection meta at the hub base, and a copy at + * every frame base that served its own SPA (i.e. registered a static mount at + * that base) whose `baseUrl` points relative resolution (the RPC dump) back at + * the hub's own meta, so a frame SPA that fetched its per-frame copy (instead + * of inheriting the host page's connection) still finds the shared dump. + */ +async function writeConnectionMetas( + ctx: DevframeHubContext, + base: string, + outDir: string, + resolveOutPath: (urlBase: string) => string, +): Promise { + const jsonSerializableMethods: string[] = [] + for (const def of ctx.rpc.definitions.values()) { + if (def.jsonSerializable === true) + jsonSerializableMethods.push(def.name) + } + const meta: ConnectionMeta = { + backend: 'static', + 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') + 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. + const servedBases = new Set(ctx.views.buildStaticDirs.map(dir => dir.baseUrl)) + for (const frame of ctx.frames) { + if (!servedBases.has(frame.base)) + continue + const target = resolve(resolveOutPath(frame.base), DEVFRAME_CONNECTION_META_FILENAME) + await fs.mkdir(dirname(target), { recursive: true }) + await fs.writeFile(target, JSON.stringify(frameMeta, null, 2), 'utf-8') + } } diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index 7af03acfb..2c9c84b03 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -123,7 +123,7 @@ export interface DevframeHubContext extends DevframeNodeContext { * Every devframe mounted into this context, in mount order. Populated by * `ctx.install` (and the batch mount `initHub`/`buildHub` run through it), * so a host that assembled and mounted the context itself can hand it to - * {@link import('./bake').bakeHubStatic} to emit the discovery documents. + * `buildHub({ context })` to emit the discovery documents. */ readonly frames: readonly HubMountedFrame[] /** diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index b2e5ba7c3..c796f3270 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -19,8 +19,8 @@ export const diagnostics = defineDiagnostics({ fix: 'The filenames directly under the hub base (`__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, `__mcp`, `embedded.js`) are reserved for the hub protocol. Rename the devframe id, or override its mount with a non-colliding `basePath`.', }, DF8002: { - why: 'initHub received both `devframes` and `context`; the two assembly modes are mutually exclusive.', - fix: 'Pass `devframes` to let the instance create the hub context and mount each frame itself, or pass a pre-built `context` (your host already mounted the frames), but never both.', + why: '`initHub`/`buildHub` received both `devframes` and `context`; the two assembly modes are mutually exclusive.', + fix: 'Pass `devframes` to let it create the hub context and mount each frame itself, or pass a pre-built `context` (your host already mounted the frames), but never both.', }, DF8003: { why: 'connectionMeta() was called before initHub finished initializing.', From f944e0ff7b389d0d378508daa5f9c41efc53bfb0 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 4 Sep 2026 01:42:13 +0000 Subject: [PATCH 4/5] test(hub): update tsnapi API snapshots for buildHub context/clean and ctx.frames --- tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts | 2 ++ tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts | 1 + tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts index 32d8b991b..4178d6ddf 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts @@ -6,6 +6,7 @@ export interface BuildHubOptions { outDir: string; base?: string; devframes?: DevframesInput; + context?: DevframeHubContext; services?: DevframeServiceInput[]; rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations']; configure?: (_: DevframeHubContext) => void | Promise; @@ -16,6 +17,7 @@ export interface BuildHubOptions { cwd?: string; getStorageDir?: (_: DevframeStorageScope) => string; pretty?: boolean; + clean?: boolean; } // #endregion diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index de628a9a0..0c2878326 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -134,6 +134,7 @@ export interface DevframeHubContext extends DevframeNodeContext { terminals: DevframeTerminalsHost; messages: DevframeMessagesHost; commands: DevframeCommandsHost; + readonly frames: readonly HubMountedFrame[]; install: (_: DevframeDefinition, _?: InstallDevframeOptions) => Promise; } export interface DevframeMessageActivateAction { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index ee478ad7d..e71d63c87 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -283,5 +283,6 @@ type PartialWithoutId Date: Fri, 4 Sep 2026 02:09:15 +0000 Subject: [PATCH 5/5] fix(hub): resolve baked statics with their mount resolveFrom buildHub copies statics from ctx.views.buildStaticDirs, which for a plugin's RemoteAssets client bundle (e.g. @devframes/plugin-a11y--assets) must re-resolve with the plugin's importMetaUrl as resolveFrom to hit the locally-installed copy; without it resolution fell back to a stale CDN back-proxy cache, baking an outdated SPA (surfaced by the a11y summary sticky e2e). Record resolveFrom on each buildStaticDirs entry and pass it through when baking. --- packages/devframe/src/node/host-views.ts | 4 ++-- packages/devframe/src/types/views.ts | 7 ++++++- packages/hub/src/node/build.ts | 7 +++++-- tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index d7e18d123..bfa668fc7 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -7,7 +7,7 @@ export class DevframeViewHost implements DevframeViewHostType { /** * @internal */ - public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] = [] + public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] = [] constructor( public readonly context: DevframeNodeContext, @@ -30,7 +30,7 @@ export class DevframeViewHost implements DevframeViewHostType { throw diagnostics.DF0008({ distDir: resolved }) } - this.buildStaticDirs.push({ baseUrl, source }) + this.buildStaticDirs.push({ baseUrl, source, resolveFrom: defaultResolveFrom }) this.context.host.mountStatic(baseUrl, resolved) } } diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index dd5c03839..0ecb594ed 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -2,9 +2,14 @@ import type { StaticAssetsSource } from './remote-assets' export interface DevframeViewHost { /** + * Static mounts registered through {@link DevframeViewHost.hostStatic}, each + * carrying the `resolveFrom` base it was mounted with so a build step that + * copies these itself (rather than serving them live) re-resolves a remote + * source to the same locally-installed copy it would serve live. + * * @internal */ - buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] + buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] /** * Helper to host static files * - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files diff --git a/packages/hub/src/node/build.ts b/packages/hub/src/node/build.ts index 9c9fe2101..6aa6e2605 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -185,12 +185,15 @@ async function createAndMountContext(options: BuildHubOptions, base: string, cwd * source by materializing every listed file. Reads the list rather than * relying on a live host `mountStatic`, so a context whose host copied no * statics at mount time (the build host, or a kit's) still gets its assets in. + * Each source re-resolves with the `resolveFrom` it was mounted with, so a + * remote source (e.g. a plugin's `--assets` package) resolves to the same + * locally-installed copy it would serve live. */ async function copyBuildStatics(ctx: DevframeHubContext, resolveOutPath: (urlBase: string) => string): Promise { const storageDir = ctx.host.getStorageDir('project') - for (const { baseUrl, source } of ctx.views.buildStaticDirs) { + for (const { baseUrl, source, resolveFrom } of ctx.views.buildStaticDirs) { const target = resolveOutPath(baseUrl) - const resolved = resolveStaticAssetsSource(source, storageDir) + const resolved = resolveStaticAssetsSource(source, storageDir, resolveFrom) await fs.mkdir(dirname(target), { recursive: true }) if (typeof resolved === 'string') await fs.cp(resolved, target, { recursive: true }) diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 9858adf5e..5724451a4 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -367,6 +367,7 @@ export interface DevframeViewHost { buildStaticDirs: { baseUrl: string; source: StaticAssetsSource; + resolveFrom?: string | null; }[]; hostStatic: (_: string, _: StaticAssetsSource, _?: string | null) => void; }