Skip to content

Commit ba2af07

Browse files
authored
feat(hub): buildHub accpets already-mounted context (#349)
1 parent ad18a64 commit ba2af07

17 files changed

Lines changed: 239 additions & 96 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` 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.

‎docs/content/6.errors/DF8002.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
2-
title: 'DF8002: Both devframes and context Passed to initHub'
3-
description: 'initHub received both devframes and context; the two assembly modes are mutually exclusive.'
2+
title: 'DF8002: Both devframes and context Passed to initHub/buildHub'
3+
description: 'initHub/buildHub received both devframes and context; the two assembly modes are mutually exclusive.'
44
---
55

66
## Message
77

8-
> initHub received both `devframes` and `context`; the two assembly modes are mutually exclusive.
8+
> `initHub`/`buildHub` received both `devframes` and `context`; the two assembly modes are mutually exclusive.
99
1010
## Cause
1111

12-
`initHub` assembles a hub two ways: **declaratively** (`devframes: [...]`, where the instance creates the hub context and mounts each devframe under `<base><id>/`), 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.
12+
`initHub` (and `buildHub`) assembles a hub two ways: **declaratively** (`devframes: [...]`, where it creates the hub context and mounts each devframe under `<base><id>/`), 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.
1313

1414
## Example
1515

@@ -33,3 +33,4 @@ Pick one mode. Use `configure(ctx)` on the declarative mode when you need post-m
3333
## Source
3434

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ 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+
| `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`. |
96+
| `clean` | Remove `outDir` before writing. Default `true`; set `false` to bake beside an app's own build output. |
9597
| `pretty` | Pretty-print RPC dump JSON shards. Default `false` (minified). |
9698

9799
## Client runtime options

‎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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ import type { StaticAssetsSource } from './remote-assets'
22

33
export interface DevframeViewHost {
44
/**
5+
* Static mounts registered through {@link DevframeViewHost.hostStatic}, each
6+
* carrying the `resolveFrom` base it was mounted with so a build step that
7+
* copies these itself (rather than serving them live) re-resolves a remote
8+
* source to the same locally-installed copy it would serve live.
9+
*
510
* @internal
611
*/
7-
buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[]
12+
buildStaticDirs: { baseUrl: string, source: StaticAssetsSource, resolveFrom?: string | null }[]
813
/**
914
* Helper to host static files
1015
* - 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ 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'
78
import { buildHub } from '../build'
9+
import { createHubContext } from '../context'
810

911
function makeDist(html: string): string {
1012
const dir = mkdtempSync(join(tmpdir(), 'hub-build-dist-'))
@@ -111,6 +113,56 @@ describe('buildHub', () => {
111113
expect(docksRecord).not.toContain('Frame live')
112114
})
113115

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

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@ 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+
}),
2331
},
32+
frames: [],
2433
/**
2534
* Minimal stub, since these tests drive dock/setup wiring, not the services
2635
* 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)