diff --git a/packages/hub/src/constants.ts b/packages/hub/src/constants.ts index f49665e8..12069b6a 100644 --- a/packages/hub/src/constants.ts +++ b/packages/hub/src/constants.ts @@ -2,6 +2,13 @@ import type { DevframeDocksUserSettings } from './types/settings' export * from 'devframe/constants' +/** + * Read-only shared-state key the hub publishes its view-provider map under + * (dock view `type` → {@link DevframeViewProviderMeta}). A UI reads this to + * resolve a provider iframe URL and to detect "no provider registered". + */ +export const VIEW_PROVIDERS_STATE_KEY = 'devframe:view-providers' + /** * The default ordering weight for each known dock category — lower sorts * earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index 919e24ad..5108c79a 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -1,4 +1,6 @@ +import type { DevframeDefinition } from 'devframe/types' import type { DevframeDockEntry } from '../../types/docks' +import type { DevframeViewProviders } from '../../types/view-providers' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,6 +8,7 @@ import { createHostContext, startHttpAndWs } from 'devframe/node' import { getInternalContext } from 'devframe/node/hub-internals' import { describe, expect, it, vi } from 'vitest' import { createHubContext } from '../context' +import { mountViewProvider } from '../mount-devframe' function createHost(storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-context-'))) { return { @@ -28,6 +31,34 @@ describe('createHubContext shared state', () => { }) }) +describe('mountViewProvider', () => { + it('publishes the provider base to shared state without registering a dock', async () => { + const context = await createHubContext({ + cwd: process.cwd(), + mode: 'build', + host: createHost(), + }) + + const def: DevframeDefinition = { + id: 'json-render', + name: 'JSON Render', + version: '0.0.0', + packageName: '@devframes/json-render-ui', + homepage: 'https://example.test', + description: 'provider', + setup: () => {}, + } + await mountViewProvider(context, 'json-render', def, { base: '/__devframes/json-render/' }) + + // The provider renders other docks — it is not a dock itself. + const docks = await context.rpc.sharedState.get('devframe:docks') + expect(docks.value()).toEqual([]) + + const providers = await context.rpc.sharedState.get('devframe:view-providers') + expect(providers.value()).toEqual({ 'json-render': { base: '/__devframes/json-render/' } }) + }) +}) + describe('createHubContext dock activation', () => { it('mirrors an activation into shared state and broadcasts it live', async () => { const context = await createHubContext({ diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 3a2e6465..939a5c34 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -19,7 +19,7 @@ import { resolve } from 'pathe' import { cleanDoubleSlashes, joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash, withTrailingSlash } from 'ufo' import { createHubContext } from './context' import { diagnostics } from './diagnostics' -import { mountDevframe } from './mount-devframe' +import { mountDevframe, mountViewProvider } from './mount-devframe' /** A `devframes` entry with per-mount dock customization. */ export interface HubDevframeEntry { @@ -114,6 +114,16 @@ export interface InitHubOptions { * (category, icon, a `clientScript` to run in the host page, …). */ devframes?: (DevframeDefinition | HubDevframeEntry)[] + /** + * View providers to register, keyed by the dock view `type` they render + * (e.g. `{ 'json-render': jsonRenderProvider() }`). Each is mounted as an SPA + * at `/` (no dock of its own) and published to the client, which + * renders that dock type in a swappable iframe. A type with no provider + * shows the UI's "no provider" placeholder. The hub stays headless: it ships + * none, and the reference json-render provider comes from + * `@devframes/json-render-ui`. + */ + viewProviders?: Record /** * Extra RPC declarations registered at context creation, alongside the * hub built-ins — forwarded to `createHubContext`'s @@ -405,6 +415,17 @@ function instantiateHub(options: InitHubOptions): HubInstance { frames.push({ id: def.id, base: frameBase, title: def.name }) } + // View providers: mounted like frames (SPA + connection meta) but without a + // dock of their own, and published to the client via shared state. + for (const [type, def] of Object.entries(options.viewProviders ?? {})) { + if ((RESERVED_HUB_PATHS as readonly string[]).includes(def.id)) + throw diagnostics.DF8000({ id: def.id }) + if (!/^[\w.-]+$/.test(def.id)) + throw diagnostics.DF8004({ id: def.id }) + const providerBase = withTrailingSlash(joinURL(base, def.id)) + await mountViewProvider(ctx, type, def, { base: providerBase }) + } + await options.configure?.(ctx) // Aggregate MCP — one Streamable-HTTP endpoint over the shared diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts index 204a6763..5dce6dc6 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/mount-devframe.ts @@ -1,8 +1,10 @@ import type { DevframeDefinition } from 'devframe/types' import type { DevframeViewIframe } from '../types/docks' +import type { DevframeViewProviders } from '../types/view-providers' import type { DevframeHubContext } from './context' import { resolveBasePath } from 'devframe/node/hub-internals' import { resolve } from 'pathe' +import { VIEW_PROVIDERS_STATE_KEY } from '../constants' import { diagnostics } from './diagnostics' export interface MountDevframeOptions { @@ -19,6 +21,13 @@ export interface MountDevframeOptions { * the devframe definition. */ dock?: Partial> + /** + * Register the auto-synthesized iframe dock entry. Default `true`. Set + * `false` to serve the SPA + connection meta and run `setup(ctx)` without + * adding a dock — used for a {@link mountViewProvider view provider}, whose + * SPA renders *other* docks rather than appearing as one itself. + */ + registerDock?: boolean } /** @@ -91,18 +100,53 @@ export async function mountDevframe( ctx.views.hostStatic(base, resolve(d.cli.distDir)) } - ctx.docks.register({ - id, - title: d.name, - icon: d.icon ?? 'ph:plug-duotone', - // Definition-level `dock` defaults sit above the name/icon-derived - // defaults; per-mount `options.dock` overrides them; `type`/`url` - // (and `id`) stay locked, derived from the definition. - ...d.dock, - ...options.dock, - type: 'iframe', - url: base, - } as DevframeViewIframe) + if (options.registerDock !== false) { + ctx.docks.register({ + id, + title: d.name, + icon: d.icon ?? 'ph:plug-duotone', + // Definition-level `dock` defaults sit above the name/icon-derived + // defaults; per-mount `options.dock` overrides them; `type`/`url` + // (and `id`) stay locked, derived from the definition. + ...d.dock, + ...options.dock, + type: 'iframe', + url: base, + } as DevframeViewIframe) + } await d.setup(ctx) } + +/** + * Mount a {@link DevframeDefinition} as a **view provider** for a dock view + * `type` (e.g. `json-render`): serves its SPA (no dock of its own) and + * publishes `type → { base }` into the read-only `VIEW_PROVIDERS_STATE_KEY` + * shared state, so a UI can render that dock type in an iframe at `base` (and + * show a placeholder when a type has no provider). Idempotent per type — a + * later registration overwrites the earlier `base`. + * + * ```ts + * await mountViewProvider(ctx, 'json-render', jsonRenderProvider(), { base }) + * ``` + * + * `initHub({ viewProviders })` calls this for each entry; hosts assembling + * `createHubContext` + `mountDevframe` themselves call it directly. + */ +export async function mountViewProvider( + ctx: DevframeHubContext, + type: string, + d: DevframeDefinition, + options: { base?: string } = {}, +): Promise<{ base: string }> { + const base = options.base ?? resolveBasePath(d, 'hosted') + await mountDevframe(ctx, d, { base, registerDock: false }) + const state = await ctx.rpc.sharedState.get( + VIEW_PROVIDERS_STATE_KEY, + { initialValue: {} }, + ) + state.mutate((map) => { + map[type] = { base } + }) + return { base } +} diff --git a/packages/hub/src/types/index.ts b/packages/hub/src/types/index.ts index e81a1c1a..2bcbb216 100644 --- a/packages/hub/src/types/index.ts +++ b/packages/hub/src/types/index.ts @@ -7,6 +7,7 @@ export * from './docks' export * from './messages' export * from './settings' export * from './terminals' +export * from './view-providers' export type { RpcDefinitionsFilter, RpcDefinitionsToFunctions } from 'devframe/rpc' diff --git a/packages/hub/src/types/view-providers.ts b/packages/hub/src/types/view-providers.ts new file mode 100644 index 00000000..cf1ed3b4 --- /dev/null +++ b/packages/hub/src/types/view-providers.ts @@ -0,0 +1,17 @@ +/** + * A view provider renders a dock view *type* (e.g. `json-render`) in a + * swappable iframe SPA, decoupling the renderer from the hub UI's framework. + * The hub mounts each provider's SPA and publishes this map as read-only shared + * state (`VIEW_PROVIDERS_STATE_KEY`); a UI resolves a dock's `type` to the + * provider `base`, mounts an iframe there, and shows a placeholder when a type + * has no provider. + */ + +/** Metadata a hub publishes for one registered view provider. */ +export interface DevframeViewProviderMeta { + /** Base URL the provider SPA is served at — point an iframe here. */ + base: string +} + +/** Map of dock view `type` → its registered iframe view provider. */ +export type DevframeViewProviders = Record diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index cb54f03c..2ab39dbd 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -4,6 +4,7 @@ // #region Variables export declare const DEFAULT_CATEGORIES_ORDER: Record; export declare const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings; +export declare const VIEW_PROVIDERS_STATE_KEY: string; // #endregion // #region Re-exports diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js index 6189f365..73007005 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.js @@ -4,6 +4,7 @@ // #region Variables export var DEFAULT_CATEGORIES_ORDER /* const */ export var DEFAULT_STATE_USER_SETTINGS /* const */ +export var VIEW_PROVIDERS_STATE_KEY /* const */ // #endregion // #region Re-exports diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 9d94c09b..d814cb1f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -306,6 +306,9 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase { onLaunch?: () => Promise; }; } +export interface DevframeViewProviderMeta { + base: string; +} export interface FrameSubTabsConfig { protocol: 'postmessage'; handshakeTimeoutMs?: number; @@ -347,6 +350,7 @@ export type DevframeMessageLevel = 'info' | 'warn' | 'error' | 'success' | 'debu export type DevframeMessageShortcutInput = Omit; export type DevframeTerminalStatus = 'running' | 'stopped' | 'error'; export type DevframeViewLauncherStatus = 'idle' | 'loading' | 'success' | 'error'; +export type DevframeViewProviders = Record; // #endregion // #region Functions diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index b53ef523..25ee6070 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -33,6 +33,7 @@ export interface HubInstance { export interface InitHubOptions { base: string; devframes?: (DevframeDefinition | HubDevframeEntry)[]; + viewProviders?: Record; rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations']; context?: DevframeHubContext; configure?: (_: DevframeHubContext) => void | Promise; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index f0601f65..dfd5aa46 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -275,4 +275,5 @@ export { DEFAULT_CATEGORIES_ORDER } export { DevframeHubContext } export { mountDevframe } export { MountDevframeOptions } +export { mountViewProvider } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index 8be2e7ef..e2a2a497 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -25,4 +25,5 @@ export { hubTerminalsRestart } export { hubTerminalsTerminate } export { hubTerminalsWrite } export { mountDevframe } +export { mountViewProvider } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index e22809a7..ecd4e709 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -69,6 +69,8 @@ export { DevframeViewHost } export { DevframeViewIframe } export { DevframeViewLauncher } export { DevframeViewLauncherStatus } +export { DevframeViewProviderMeta } +export { DevframeViewProviders } export { EntriesToObject } export { EventEmitter } export { EventsMap }