diff --git a/docs/content/1.guide/18.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md index 843e242b..7eca5d28 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` 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 905afaa5..79be99e5 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/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index b5b3cfb3..d1264919 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -92,6 +92,8 @@ 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). | ## Client runtime options diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index d7e18d12..bfa668fc 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 dd5c0383..0ecb594e 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/__tests__/build.test.ts b/packages/hub/src/node/__tests__/build.test.ts index cbe9539f..3be80ef6 100644 --- a/packages/hub/src/node/__tests__/build.test.ts +++ b/packages/hub/src/node/__tests__/build.test.ts @@ -2,9 +2,11 @@ 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 { buildHub } from '../build' +import { createHubContext } from '../context' function makeDist(html: string): string { const dir = mkdtempSync(join(tmpdir(), 'hub-build-dist-')) @@ -111,6 +113,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 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 `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 buildHub({ context: ctx, outDir, base: '/__hub/' }) + + // 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']) + 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 61cc0ca7..e48188e4 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -12,15 +12,24 @@ 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) + }), }, + 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 914a87a3..fea38707 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/build.ts b/packages/hub/src/node/build.ts index b22305c3..6aa6e260 100644 --- a/packages/hub/src/node/build.ts +++ b/packages/hub/src/node/build.ts @@ -10,6 +10,7 @@ import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME } fro import { createH3DevframeHost } from 'devframe/internal' 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' @@ -33,6 +34,17 @@ export interface BuildHubOptions { 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. */ @@ -62,15 +74,23 @@ export interface BuildHubOptions { 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`: * - * - Build a `mode: 'build'` hub context and mount every devframe: each - * SPA is copied to `//`, an absolute-path page script to - * `//__page-script/`, and its `setup(ctx)` runs. + * - 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`). @@ -82,58 +102,61 @@ export interface BuildHubOptions { * (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. + * 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 ?? []) - if (existsSync(outDir)) + 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`. */ - function resolveOutPath(urlBase: string): string { + const 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[] = [] + 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) - 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) - }, - }) + 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 } : {}), - 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) - }, + mountConnectionMeta: () => {}, } const ctx = await createHubContext({ @@ -147,41 +170,36 @@ 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) await run() - 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, - 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 }) + return ctx +} - console.log(c.green`[devframes-hub] built ${frames.length} devframe${frames.length === 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 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, 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) + } } /** @@ -228,18 +246,39 @@ async function writeUiArtifacts(ui: DevframeHubUi | undefined, outDir: string): } } +/** 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, + 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 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. + * 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, - frameDirs: readonly string[], + resolveOutPath: (urlBase: string) => string, ): Promise { const jsonSerializableMethods: string[] = [] for (const def of ctx.rpc.definitions.values()) { @@ -253,8 +292,13 @@ 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) } - for (const frameDir of frameDirs) { - const target = resolve(frameDir, 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 e52b5e41..2c9c84b0 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -85,6 +85,21 @@ 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`. + */ +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 +} + /** * Hub-augmented node context that extends devframe's framework-neutral * `DevframeNodeContext` with the hub-level subsystems (`docks`, @@ -104,6 +119,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 + * `buildHub({ context })` 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 +168,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/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index b2e5ba7c..c796f327 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.', diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index f0dbbe05..0a73f319 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 bdc59d71..90e836ce 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)) } } @@ -114,7 +117,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 } @@ -157,6 +160,8 @@ export async function prepareDevframe( await serveDevframeAssets(ctx, d, id, base) + ;(ctx.frames as HubMountedFrame[]).push({ id, base, title: d.name }) + ctx.docks.register({ id, title: d.name, diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/build.snapshot.d.ts index 32d8b991..4178d6dd 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 de628a9a..0c287832 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 ee478ad7..e71d63c8 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 void; }