Skip to content

Commit 5bafc33

Browse files
committed
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.
1 parent e3af508 commit 5bafc33

13 files changed

Lines changed: 397 additions & 205 deletions

File tree

docs/content/1.guide/18.hub-initiate.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,5 @@ const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx })
126126
```
127127

128128
It then serves only hub-level endpoints and transport; serve each mounted devframe's meta from `hub.connectionMeta()` yourself.
129+
130+
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.

docs/content/6.errors/DF8006.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description: 'A static hub build can only write mounts under its own base: "{url
99
1010
## Cause
1111

12-
`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`.
12+
`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`.
1313

1414
## Example
1515

@@ -30,4 +30,4 @@ await buildHub({
3030

3131
## Source
3232

33-
- [`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.
33+
- [`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.

docs/content/8.references/6.hub-api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,13 @@ The options of `buildHub()` from `@devframes/hub/build`: [Static builds](/guide/
9292
|---|---|
9393
| `outDir` | Output directory for the hub subtree; corresponds to `base` at serve time (build `base: '/__devframes/'` into `dist/__devframes`). |
9494
| `base` | Mount base baked into every absolute URL the build emits. Default `/__devframes/`. |
95+
| `clean` | Remove `outDir` before writing. Default `true`; set `false` to bake beside an app's own build output. |
9596
| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |
9697

98+
## `bakeHubStatic` options
99+
100+
`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.
101+
97102
## Client runtime options
98103

99104
The options of `createDevframeClientRuntime()`: [The client runtime](/guide/client-context#the-client-runtime).

packages/devframe/src/node/host-views.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class DevframeViewHost implements DevframeViewHostType {
77
/**
88
* @internal
99
*/
10-
public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] = []
10+
public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[] = []
1111

1212
constructor(
1313
public readonly context: DevframeNodeContext,
@@ -30,7 +30,7 @@ export class DevframeViewHost implements DevframeViewHostType {
3030
throw diagnostics.DF0008({ distDir: resolved })
3131
}
3232

33-
this.buildStaticDirs.push({ baseUrl, source })
33+
this.buildStaticDirs.push({ baseUrl, source, resolveFrom: defaultResolveFrom })
3434
this.context.host.mountStatic(baseUrl, resolved)
3535
}
3636
}

packages/devframe/src/types/views.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import type { StaticAssetsSource } from './remote-assets'
22

33
export interface DevframeViewHost {
44
/**
5+
* Static mounts registered through {@link DevframeViewHost.hostStatic}, in
6+
* registration order, each carrying the `resolveFrom` base it was mounted
7+
* with so a build step can re-resolve a remote source identically. A static
8+
* build that assembles the context itself (rather than serving it live)
9+
* copies these into its output.
10+
*
511
* @internal
612
*/
7-
buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[]
13+
buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[]
814
/**
915
* Helper to host static files
1016
* - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files

packages/hub/src/node/__tests__/build.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import type { DevframeDefinition, DevframeNodeContext } from 'devframe/types'
22
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
5+
import { createH3DevframeHost } from 'devframe/internal'
56
import { describe, expect, it } from 'vitest'
67
import { HUB_EVENTS } from '../../events'
8+
import { bakeHubStatic } from '../bake'
79
import { buildHub } from '../build'
10+
import { createHubContext } from '../context'
811

912
function makeDist(html: string): string {
1013
const dir = mkdtempSync(join(tmpdir(), 'hub-build-dist-'))
@@ -111,6 +114,56 @@ describe('buildHub', () => {
111114
expect(docksRecord).not.toContain('Frame live')
112115
})
113116

117+
it('keeps sibling output when clean is false', async () => {
118+
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
119+
const appFile = join(outDir, 'app.js')
120+
121+
await buildHub({
122+
outDir,
123+
base: '/__hub/',
124+
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
125+
devframes: [makeFrame('alpha', { distDir: makeDist('<h1>alpha</h1>') })],
126+
})
127+
writeFileSync(appFile, 'app', 'utf-8')
128+
129+
await buildHub({
130+
outDir,
131+
base: '/__hub/',
132+
clean: false,
133+
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
134+
devframes: [makeFrame('beta', { distDir: makeDist('<h1>beta</h1>') })],
135+
})
136+
137+
// The pre-existing sibling file survives, and the re-bake lands beside it.
138+
expect(existsSync(appFile)).toBe(true)
139+
expect(readFileSync(join(outDir, 'beta/index.html'), 'utf-8')).toContain('beta')
140+
})
141+
142+
it('bakes an externally-mounted context via bakeHubStatic', async () => {
143+
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-bake-out-')), 'hub')
144+
const cwd = mkdtempSync(join(tmpdir(), 'hub-bake-cwd-'))
145+
146+
// A host assembling the context itself: create + mount via `ctx.install`,
147+
// then hand the already-mounted context to the baker.
148+
const host = createH3DevframeHost({ origin: 'http://localhost', appName: 'devframes', workspaceRoot: cwd, mount: () => {} })
149+
const ctx = await createHubContext({ cwd, workspaceRoot: cwd, mode: 'build', host })
150+
await ctx.install(makeFrame('alpha', { distDir: makeDist('<h1>alpha</h1>') }), { base: '/__hub/alpha/' })
151+
152+
expect(ctx.frames.map(frame => frame.id)).toEqual(['alpha'])
153+
154+
await bakeHubStatic(ctx, { outDir, base: '/__hub/' })
155+
156+
// The baker copied the SPA from `ctx.views.buildStaticDirs`, wrote the
157+
// index from `ctx.frames`, and emitted the per-frame meta + shared dump.
158+
expect(readFileSync(join(outDir, 'alpha/index.html'), 'utf-8')).toContain('alpha')
159+
const index = JSON.parse(readFileSync(join(outDir, '__index.json'), 'utf-8'))
160+
expect(index.frames.map((frame: { id: string }) => frame.id)).toEqual(['alpha'])
161+
const frameMeta = JSON.parse(readFileSync(join(outDir, 'alpha/__connection.json'), 'utf-8'))
162+
expect(frameMeta.baseUrl).toBe('/__hub/__connection.json')
163+
const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8'))
164+
expect(manifest['alpha:probe']).toMatchObject({ type: 'static' })
165+
})
166+
114167
it('rejects a mount base outside the hub base', async () => {
115168
const outDir = join(mkdtempSync(join(tmpdir(), 'hub-build-out-')), 'hub')
116169
await expect(buildHub({

packages/hub/src/node/__tests__/install-devframe.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,25 @@ type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }
1212

1313
function createContext(): DevframeHubContext {
1414
const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-install-'))
15+
const mountStatic = vi.fn()
1516
const partial: DeepPartial<DevframeHubContext> = {
1617
host: {
17-
mountStatic: vi.fn(),
18+
mountStatic,
1819
resolveOrigin: () => 'http://localhost:5173',
1920
getStorageDir: () => storageDir,
2021
},
2122
views: {
22-
hostStatic: () => {},
23+
/**
24+
* Mirror the real view host: forward to `host.mountStatic` so the tests
25+
* assert the static mount the same way they did before page scripts and
26+
* SPAs routed through `views.hostStatic`.
27+
*/
28+
hostStatic: vi.fn((baseUrl: string, source: unknown) => {
29+
mountStatic(baseUrl, source as string)
30+
}),
31+
buildStaticDirs: [],
2332
},
33+
frames: [],
2434
/**
2535
* Minimal stub, since these tests drive dock/setup wiring, not the services
2636
* lifecycle (the demo devframe declares none).

packages/hub/src/node/assemble.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { resolve } from 'pathe'
88
import { joinURL, withTrailingSlash } from 'ufo'
99
import { resolveClientModuleSpecifier } from '../client-modules'
1010
import { diagnostics } from './diagnostics'
11-
import { prepareDevframe, skippedInStaticBuild } from './install-devframe'
11+
import { prepareDevframe } from './install-devframe'
1212

1313
/** Reserved filenames directly under the hub base; a frame id can't shadow them. */
1414
const RESERVED_HUB_PATHS = [
@@ -100,13 +100,13 @@ export function renderClientImportsModule(ctx: DevframeHubContext): string {
100100
/**
101101
* Pass 1: mount each devframe under `<base><id>/` (SPA, meta, iframe dock)
102102
* and queue its declared services, guarding the id against reserved hub
103-
* filenames and route-pattern characters. Returns the deferred setup thunks.
103+
* filenames and route-pattern characters. Returns the deferred setup thunks;
104+
* each mounted frame is recorded on `ctx.frames`.
104105
*/
105106
export async function mountDevframes(
106107
ctx: DevframeHubContext,
107108
devframes: HubDevframeEntry[],
108109
base: string,
109-
frames: { id: string, base: string, title: string }[],
110110
hubMcpEnabled: boolean,
111111
): Promise<(() => Promise<void>)[]> {
112112
const setups: (() => Promise<void>)[] = []
@@ -129,10 +129,6 @@ export async function mountDevframes(
129129
const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) })
130130
if (run)
131131
setups.push(run)
132-
// A devframe skipped by the static build serves nothing, so it never
133-
// joins the `__index.json` frame list either.
134-
if (!skippedInStaticBuild(ctx, def))
135-
frames.push({ id: def.id, base: frameBase, title: def.name })
136132
}
137133
return setups
138134
}

0 commit comments

Comments
 (0)