Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions docs/content/6.errors/DF8002.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
`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.

## Example

Expand All @@ -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.
2 changes: 2 additions & 0 deletions docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/devframe/src/node/host-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}
7 changes: 6 additions & 1 deletion packages/devframe/src/types/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions packages/hub/src/node/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'))
Expand Down Expand Up @@ -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('<h1>alpha</h1>') })],
})
writeFileSync(appFile, 'app', 'utf-8')

await buildHub({
outDir,
base: '/__hub/',
clean: false,
cwd: mkdtempSync(join(tmpdir(), 'hub-build-cwd-')),
devframes: [makeFrame('beta', { distDir: makeDist('<h1>beta</h1>') })],
})

// 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('<h1>alpha</h1>') }), { 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({
Expand Down
13 changes: 11 additions & 2 deletions packages/hub/src/node/__tests__/install-devframe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,24 @@ type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }

function createContext(): DevframeHubContext {
const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-install-'))
const mountStatic = vi.fn()
const partial: DeepPartial<DevframeHubContext> = {
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).
Expand Down
10 changes: 3 additions & 7 deletions packages/hub/src/node/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -100,13 +100,13 @@ export function renderClientImportsModule(ctx: DevframeHubContext): string {
/**
* Pass 1: mount each devframe under `<base><id>/` (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<void>)[]> {
const setups: (() => Promise<void>)[] = []
Expand All @@ -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
}
Loading
Loading