Skip to content

Commit 95e077b

Browse files
committed
feat(hub): generic view-provider mechanism (initHub viewProviders + shared state)
A dock view *type* can resolve to a swappable iframe provider SPA: - initHub({ viewProviders: { <type>: DevframeDefinition } }) mounts each provider's SPA at <base><id>/ (serving its connection meta + statics) and publishes <type> -> { base } to the read-only devframe:view-providers shared state a UI reads to resolve the iframe URL (and to detect 'no provider'). - mountDevframe gains registerDock?: boolean; mountViewProvider(ctx, type, def) is the reusable primitive (used by initHub, callable on the manual createHubContext path). The headless core ships no provider — the reference json-render provider comes from @devframes/json-render-ui. - Keeps the in-process renderers seam untouched. Adds VIEW_PROVIDERS_STATE_KEY + DevframeViewProviders types.
1 parent b089840 commit 95e077b

13 files changed

Lines changed: 145 additions & 13 deletions

File tree

packages/hub/src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import type { DevframeDocksUserSettings } from './types/settings'
22

33
export * from 'devframe/constants'
44

5+
/**
6+
* Read-only shared-state key the hub publishes its view-provider map under
7+
* (dock view `type` → {@link DevframeViewProviderMeta}). A UI reads this to
8+
* resolve a provider iframe URL and to detect "no provider registered".
9+
*/
10+
export const VIEW_PROVIDERS_STATE_KEY = 'devframe:view-providers'
11+
512
/**
613
* The default ordering weight for each known dock category — lower sorts
714
* earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import type { DevframeDefinition } from 'devframe/types'
12
import type { DevframeDockEntry } from '../../types/docks'
3+
import type { DevframeViewProviders } from '../../types/view-providers'
24
import { mkdtempSync } from 'node:fs'
35
import { tmpdir } from 'node:os'
46
import { join } from 'node:path'
57
import { createHostContext, startHttpAndWs } from 'devframe/node'
68
import { getInternalContext } from 'devframe/node/hub-internals'
79
import { describe, expect, it, vi } from 'vitest'
810
import { createHubContext } from '../context'
11+
import { mountViewProvider } from '../mount-devframe'
912

1013
function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) {
1114
return {
@@ -28,6 +31,34 @@ describe('createHubContext shared state', () => {
2831
})
2932
})
3033

34+
describe('mountViewProvider', () => {
35+
it('publishes the provider base to shared state without registering a dock', async () => {
36+
const context = await createHubContext({
37+
cwd: process.cwd(),
38+
mode: 'build',
39+
host: createHost(),
40+
})
41+
42+
const def: DevframeDefinition = {
43+
id: 'json-render',
44+
name: 'JSON Render',
45+
version: '0.0.0',
46+
packageName: '@devframes/json-render-ui',
47+
homepage: 'https://example.test',
48+
description: 'provider',
49+
setup: () => {},
50+
}
51+
await mountViewProvider(context, 'json-render', def, { base: '/__devframes/json-render/' })
52+
53+
// The provider renders other docks — it is not a dock itself.
54+
const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
55+
expect(docks.value()).toEqual([])
56+
57+
const providers = await context.rpc.sharedState.get<DevframeViewProviders>('devframe:view-providers')
58+
expect(providers.value()).toEqual({ 'json-render': { base: '/__devframes/json-render/' } })
59+
})
60+
})
61+
3162
describe('createHubContext dock activation', () => {
3263
it('mirrors an activation into shared state and broadcasts it live', async () => {
3364
const context = await createHubContext({

packages/hub/src/node/initiate.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { resolve } from 'pathe'
1919
import { cleanDoubleSlashes, joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash, withTrailingSlash } from 'ufo'
2020
import { createHubContext } from './context'
2121
import { diagnostics } from './diagnostics'
22-
import { mountDevframe } from './mount-devframe'
22+
import { mountDevframe, mountViewProvider } from './mount-devframe'
2323

2424
/** A `devframes` entry with per-mount dock customization. */
2525
export interface HubDevframeEntry {
@@ -114,6 +114,16 @@ export interface InitHubOptions {
114114
* (category, icon, a `clientScript` to run in the host page, …).
115115
*/
116116
devframes?: (DevframeDefinition | HubDevframeEntry)[]
117+
/**
118+
* View providers to register, keyed by the dock view `type` they render
119+
* (e.g. `{ 'json-render': jsonRenderProvider() }`). Each is mounted as an SPA
120+
* at `<base><id>/` (no dock of its own) and published to the client, which
121+
* renders that dock type in a swappable iframe. A type with no provider
122+
* shows the UI's "no provider" placeholder. The hub stays headless: it ships
123+
* none, and the reference json-render provider comes from
124+
* `@devframes/json-render-ui`.
125+
*/
126+
viewProviders?: Record<string, DevframeDefinition>
117127
/**
118128
* Extra RPC declarations registered at context creation, alongside the
119129
* hub built-ins — forwarded to `createHubContext`'s
@@ -405,6 +415,17 @@ function instantiateHub(options: InitHubOptions): HubInstance {
405415
frames.push({ id: def.id, base: frameBase, title: def.name })
406416
}
407417

418+
// View providers: mounted like frames (SPA + connection meta) but without a
419+
// dock of their own, and published to the client via shared state.
420+
for (const [type, def] of Object.entries(options.viewProviders ?? {})) {
421+
if ((RESERVED_HUB_PATHS as readonly string[]).includes(def.id))
422+
throw diagnostics.DF8000({ id: def.id })
423+
if (!/^[\w.-]+$/.test(def.id))
424+
throw diagnostics.DF8004({ id: def.id })
425+
const providerBase = withTrailingSlash(joinURL(base, def.id))
426+
await mountViewProvider(ctx, type, def, { base: providerBase })
427+
}
428+
408429
await options.configure?.(ctx)
409430

410431
// Aggregate MCP — one Streamable-HTTP endpoint over the shared

packages/hub/src/node/mount-devframe.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { DevframeDefinition } from 'devframe/types'
22
import type { DevframeViewIframe } from '../types/docks'
3+
import type { DevframeViewProviders } from '../types/view-providers'
34
import type { DevframeHubContext } from './context'
45
import { resolveBasePath } from 'devframe/node/hub-internals'
56
import { resolve } from 'pathe'
7+
import { VIEW_PROVIDERS_STATE_KEY } from '../constants'
68
import { diagnostics } from './diagnostics'
79

810
export interface MountDevframeOptions {
@@ -19,6 +21,13 @@ export interface MountDevframeOptions {
1921
* the devframe definition.
2022
*/
2123
dock?: Partial<Omit<DevframeViewIframe, 'id' | 'type' | 'url'>>
24+
/**
25+
* Register the auto-synthesized iframe dock entry. Default `true`. Set
26+
* `false` to serve the SPA + connection meta and run `setup(ctx)` without
27+
* adding a dock — used for a {@link mountViewProvider view provider}, whose
28+
* SPA renders *other* docks rather than appearing as one itself.
29+
*/
30+
registerDock?: boolean
2231
}
2332

2433
/**
@@ -91,18 +100,53 @@ export async function mountDevframe(
91100
ctx.views.hostStatic(base, resolve(d.cli.distDir))
92101
}
93102

94-
ctx.docks.register({
95-
id,
96-
title: d.name,
97-
icon: d.icon ?? 'ph:plug-duotone',
98-
// Definition-level `dock` defaults sit above the name/icon-derived
99-
// defaults; per-mount `options.dock` overrides them; `type`/`url`
100-
// (and `id`) stay locked, derived from the definition.
101-
...d.dock,
102-
...options.dock,
103-
type: 'iframe',
104-
url: base,
105-
} as DevframeViewIframe)
103+
if (options.registerDock !== false) {
104+
ctx.docks.register({
105+
id,
106+
title: d.name,
107+
icon: d.icon ?? 'ph:plug-duotone',
108+
// Definition-level `dock` defaults sit above the name/icon-derived
109+
// defaults; per-mount `options.dock` overrides them; `type`/`url`
110+
// (and `id`) stay locked, derived from the definition.
111+
...d.dock,
112+
...options.dock,
113+
type: 'iframe',
114+
url: base,
115+
} as DevframeViewIframe)
116+
}
106117

107118
await d.setup(ctx)
108119
}
120+
121+
/**
122+
* Mount a {@link DevframeDefinition} as a **view provider** for a dock view
123+
* `type` (e.g. `json-render`): serves its SPA (no dock of its own) and
124+
* publishes `type → { base }` into the read-only `VIEW_PROVIDERS_STATE_KEY`
125+
* shared state, so a UI can render that dock type in an iframe at `base` (and
126+
* show a placeholder when a type has no provider). Idempotent per type — a
127+
* later registration overwrites the earlier `base`.
128+
*
129+
* ```ts
130+
* await mountViewProvider(ctx, 'json-render', jsonRenderProvider(), { base })
131+
* ```
132+
*
133+
* `initHub({ viewProviders })` calls this for each entry; hosts assembling
134+
* `createHubContext` + `mountDevframe` themselves call it directly.
135+
*/
136+
export async function mountViewProvider(
137+
ctx: DevframeHubContext,
138+
type: string,
139+
d: DevframeDefinition,
140+
options: { base?: string } = {},
141+
): Promise<{ base: string }> {
142+
const base = options.base ?? resolveBasePath(d, 'hosted')
143+
await mountDevframe(ctx, d, { base, registerDock: false })
144+
const state = await ctx.rpc.sharedState.get<DevframeViewProviders>(
145+
VIEW_PROVIDERS_STATE_KEY,
146+
{ initialValue: {} },
147+
)
148+
state.mutate((map) => {
149+
map[type] = { base }
150+
})
151+
return { base }
152+
}

packages/hub/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './docks'
77
export * from './messages'
88
export * from './settings'
99
export * from './terminals'
10+
export * from './view-providers'
1011

1112
export type { RpcDefinitionsFilter, RpcDefinitionsToFunctions } from 'devframe/rpc'
1213

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* A view provider renders a dock view *type* (e.g. `json-render`) in a
3+
* swappable iframe SPA, decoupling the renderer from the hub UI's framework.
4+
* The hub mounts each provider's SPA and publishes this map as read-only shared
5+
* state (`VIEW_PROVIDERS_STATE_KEY`); a UI resolves a dock's `type` to the
6+
* provider `base`, mounts an iframe there, and shows a placeholder when a type
7+
* has no provider.
8+
*/
9+
10+
/** Metadata a hub publishes for one registered view provider. */
11+
export interface DevframeViewProviderMeta {
12+
/** Base URL the provider SPA is served at — point an iframe here. */
13+
base: string
14+
}
15+
16+
/** Map of dock view `type` → its registered iframe view provider. */
17+
export type DevframeViewProviders = Record<string, DevframeViewProviderMeta>

tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// #region Variables
55
export declare const DEFAULT_CATEGORIES_ORDER: Record<string, number>;
66
export declare const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings;
7+
export declare const VIEW_PROVIDERS_STATE_KEY: string;
78
// #endregion
89

910
// #region Re-exports

tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// #region Variables
55
export var DEFAULT_CATEGORIES_ORDER /* const */
66
export var DEFAULT_STATE_USER_SETTINGS /* const */
7+
export var VIEW_PROVIDERS_STATE_KEY /* const */
78
// #endregion
89

910
// #region Re-exports

tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase {
306306
onLaunch?: () => Promise<void>;
307307
};
308308
}
309+
export interface DevframeViewProviderMeta {
310+
base: string;
311+
}
309312
export interface FrameSubTabsConfig {
310313
protocol: 'postmessage';
311314
handshakeTimeoutMs?: number;
@@ -347,6 +350,7 @@ export type DevframeMessageLevel = 'info' | 'warn' | 'error' | 'success' | 'debu
347350
export type DevframeMessageShortcutInput = Omit<DevframeMessageEntryInput, 'message' | 'level'>;
348351
export type DevframeTerminalStatus = 'running' | 'stopped' | 'error';
349352
export type DevframeViewLauncherStatus = 'idle' | 'loading' | 'success' | 'error';
353+
export type DevframeViewProviders = Record<string, DevframeViewProviderMeta>;
350354
// #endregion
351355

352356
// #region Functions

tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface HubInstance {
3333
export interface InitHubOptions {
3434
base: string;
3535
devframes?: (DevframeDefinition | HubDevframeEntry)[];
36+
viewProviders?: Record<string, DevframeDefinition>;
3637
rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations'];
3738
context?: DevframeHubContext;
3839
configure?: (_: DevframeHubContext) => void | Promise<void>;

0 commit comments

Comments
 (0)