Skip to content
Closed
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
7 changes: 7 additions & 0 deletions packages/hub/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/hub/src/node/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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'
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 {
Expand All @@ -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<DevframeDockEntry[]>('devframe:docks')
expect(docks.value()).toEqual([])

const providers = await context.rpc.sharedState.get<DevframeViewProviders>('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({
Expand Down
23 changes: 22 additions & 1 deletion packages/hub/src/node/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 `<base><id>/` (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<string, DevframeDefinition>
/**
* Extra RPC declarations registered at context creation, alongside the
* hub built-ins — forwarded to `createHubContext`'s
Expand Down Expand Up @@ -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
Expand Down
68 changes: 56 additions & 12 deletions packages/hub/src/node/mount-devframe.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -19,6 +21,13 @@ export interface MountDevframeOptions {
* the devframe definition.
*/
dock?: Partial<Omit<DevframeViewIframe, 'id' | 'type' | 'url'>>
/**
* 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
}

/**
Expand Down Expand Up @@ -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<DevframeViewProviders>(
VIEW_PROVIDERS_STATE_KEY,
{ initialValue: {} },
)
state.mutate((map) => {
map[type] = { base }
})
return { base }
}
1 change: 1 addition & 0 deletions packages/hub/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
17 changes: 17 additions & 0 deletions packages/hub/src/types/view-providers.ts
Original file line number Diff line number Diff line change
@@ -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<string, DevframeViewProviderMeta>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// #region Variables
export declare const DEFAULT_CATEGORIES_ORDER: Record<string, number>;
export declare const DEFAULT_STATE_USER_SETTINGS: () => DevframeDocksUserSettings;
export declare const VIEW_PROVIDERS_STATE_KEY: string;
// #endregion

// #region Re-exports
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase {
onLaunch?: () => Promise<void>;
};
}
export interface DevframeViewProviderMeta {
base: string;
}
export interface FrameSubTabsConfig {
protocol: 'postmessage';
handshakeTimeoutMs?: number;
Expand Down Expand Up @@ -347,6 +350,7 @@ export type DevframeMessageLevel = 'info' | 'warn' | 'error' | 'success' | 'debu
export type DevframeMessageShortcutInput = Omit<DevframeMessageEntryInput, 'message' | 'level'>;
export type DevframeTerminalStatus = 'running' | 'stopped' | 'error';
export type DevframeViewLauncherStatus = 'idle' | 'loading' | 'success' | 'error';
export type DevframeViewProviders = Record<string, DevframeViewProviderMeta>;
// #endregion

// #region Functions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface HubInstance {
export interface InitHubOptions {
base: string;
devframes?: (DevframeDefinition | HubDevframeEntry)[];
viewProviders?: Record<string, DevframeDefinition>;
rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations'];
context?: DevframeHubContext;
configure?: (_: DevframeHubContext) => void | Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,5 @@ export { DEFAULT_CATEGORIES_ORDER }
export { DevframeHubContext }
export { mountDevframe }
export { MountDevframeOptions }
export { mountViewProvider }
// #endregion
1 change: 1 addition & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export { hubTerminalsRestart }
export { hubTerminalsTerminate }
export { hubTerminalsWrite }
export { mountDevframe }
export { mountViewProvider }
// #endregion
2 changes: 2 additions & 0 deletions tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export { DevframeViewHost }
export { DevframeViewIframe }
export { DevframeViewLauncher }
export { DevframeViewLauncherStatus }
export { DevframeViewProviderMeta }
export { DevframeViewProviders }
export { EntriesToObject }
export { EventEmitter }
export { EventsMap }
Expand Down
Loading