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;
}