Skip to content

Commit 615ff68

Browse files
committed
feat(examples): render a json-render dock in the minimal-next hub
Verify the JSON-render hub integration in the React/Next shell via registry replacement (the path a non-Vue host uses, per the plan): - The Next hub authors the shared minimal-json-render view on its hub context and projects it onto a `json-render` dock (via @devframes/json-render/hub). - A small in-example React registry (built on @json-render/react + the shared @antfu/design tokens) is registered as the `json-render` renderer on the client host; the page mounts the active dock into a panel via the renderer registry. Next.js can't compile Vue SFCs, so the Vue @devframes/json-render-ui isn't used here — this is registry replacement. - UnoCSS scans the new dir + safelists the badge colors. Docs: note the json-render dock in both hub example pages and the guide. Created with the help of an agent.
1 parent e018974 commit 615ff68

12 files changed

Lines changed: 611 additions & 17 deletions

File tree

‎docs/examples/minimal-next-devframe-hub.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)**
1515
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock.
1616
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
1717
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`, which starts the singleton host on demand.
18+
- The [JSON-render](/guide/json-render) hub integration with **registry replacement**: the host authors a view and projects it onto a `json-render` dock, and the React client renders it with a small in-example React registry (rather than the Vue `@devframes/json-render-ui`) — the path a non-Vue host uses.
1819

1920
## Run it
2021

‎docs/examples/minimal-vite-devframe-hub.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)**
1515
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock.
1616
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
1717
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware.
18+
- The opt-in [JSON-render](/guide/json-render) hub integration end to end: the host authors a view on its hub context and projects it onto a `json-render` dock, and the client host renders it via `@devframes/json-render-ui` (registered through `createDevframeClientHost({ renderers })`).
1819

1920
## Run it
2021

‎docs/guide/json-render.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ upstreamVersion }`) — no functions cross the wire. The client host disposes th
174174
renderer when the dock deactivates. A renderer/upstream-version mismatch logs a
175175
warning rather than blocking.
176176

177+
Both hub example shells dogfood this end to end: the [Vite hub](/examples/minimal-vite-devframe-hub)
178+
registers `@devframes/json-render-ui` (Vue), and the [Next hub](/examples/minimal-next-devframe-hub)
179+
registers a small in-example React registry — the same dock, two frontends.
180+
177181
## Swapping the frontend
178182

179183
A third party replaces the whole registry — pass a custom `registry` to

‎examples/minimal-json-render/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,12 @@ node bin.mjs build --out-dir dist/static # static snapshot (read-only; actions
3333

3434
In the static build, the spec + state are snapshotted as a read-only render and
3535
the action bridge reports actions as unavailable — there is no live RPC.
36+
37+
## Reused by the hub shells
38+
39+
The view is factored into `src/dashboard.ts` and exported as
40+
`minimal-json-render/dashboard` (`createDashboardView(ctx)` + `dashboardSpec`),
41+
so the hub examples plug the very same view into their hub context and project
42+
it onto a `json-render` dock — the [Vite hub](../minimal-vite-devframe-hub)
43+
renders it with `@devframes/json-render-ui` (Vue), and the
44+
[Next hub](../minimal-next-devframe-hub) renders it with a small React registry.

‎examples/minimal-next-devframe-hub/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
"dependencies": {
1414
"@antfu/design": "catalog:frontend",
1515
"@devframes/hub": "workspace:*",
16+
"@devframes/json-render": "workspace:*",
1617
"@devframes/plugin-a11y": "workspace:*",
1718
"@devframes/plugin-code-server": "workspace:*",
1819
"@devframes/plugin-git": "workspace:*",
1920
"@devframes/plugin-inspect": "workspace:*",
2021
"@devframes/plugin-messages": "workspace:*",
2122
"@devframes/plugin-terminals": "workspace:*",
23+
"@json-render/react": "catalog:frontend",
2224
"colorjs.io": "catalog:frontend",
2325
"devframe": "workspace:*",
26+
"minimal-json-render": "workspace:*",
2427
"next": "catalog:frontend",
2528
"react": "catalog:frontend",
2629
"react-dom": "catalog:frontend"

‎examples/minimal-next-devframe-hub/src/client/app/page.tsx‎

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '@devframes/hub/types'
1010
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
1111
import { useEffect, useMemo, useRef, useState } from 'react'
12+
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
1213
import { iconClass } from './icons'
1314

1415
const HUB_BASE = '/__hub/'
@@ -20,11 +21,19 @@ interface Status {
2021

2122
type IframeDock = DevframeDockEntry & { type: 'iframe', url: string }
2223
type TerminalSummary = Pick<DevframeTerminalSession, 'id' | 'title' | 'status' | 'description'>
24+
type ClientHost = Awaited<ReturnType<typeof createDevframeClientHost>>
2325

2426
function isIframeDock(d: DevframeDockEntry): d is IframeDock {
2527
return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
2628
}
2729

30+
// A dock this shell can display: an iframe, or one with a registered renderer
31+
// (the json-render dock, rendered by the mini React registry).
32+
const RENDERER_TYPES = new Set(['json-render'])
33+
function isRenderableDock(d: DevframeDockEntry): boolean {
34+
return isIframeDock(d) || RENDERER_TYPES.has(d.type)
35+
}
36+
2837
/** Render a dock icon, falling back to the title's initial when unmapped. */
2938
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
3039
const cls = iconClass(entry.icon)
@@ -43,6 +52,8 @@ export default function Page() {
4352
const [pingResult, setPingResult] = useState('Run ping')
4453
const [selectedDockId, setSelectedDockId] = useState<string | null>(null)
4554
const rpcRef = useRef<DevframeRpcClient | null>(null)
55+
const hostRef = useRef<ClientHost | null>(null)
56+
const panelRef = useRef<HTMLDivElement | null>(null)
4657

4758
useEffect(() => {
4859
let cancelled = false
@@ -60,7 +71,14 @@ export default function Page() {
6071
// Boot the framework-level client host: it builds the shared client
6172
// context and imports each dock's client script into this page — e.g.
6273
// the a11y inspector's in-page agent, which then scans this hub live.
63-
const clientHost = await createDevframeClientHost({ rpc })
74+
//
75+
// Register a mini React json-render renderer so the hub can display the
76+
// `json-render` dock authored server-side via @devframes/json-render.
77+
const clientHost = await createDevframeClientHost({
78+
rpc,
79+
renderers: { 'json-render': createReactJsonRenderDockRenderer() },
80+
})
81+
hostRef.current = clientHost
6482

6583
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
6684
'devframe:docks',
@@ -122,18 +140,41 @@ export default function Page() {
122140
}
123141
}, [])
124142

125-
const iframeDocks = useMemo(() => docks.filter(isIframeDock), [docks])
143+
const renderableDocks = useMemo(() => docks.filter(isRenderableDock), [docks])
126144

127145
useEffect(() => {
128-
if (selectedDockId && !iframeDocks.some(d => d.id === selectedDockId)) {
146+
if (selectedDockId && !renderableDocks.some(d => d.id === selectedDockId)) {
129147
setSelectedDockId(null)
130148
return
131149
}
132-
if (!selectedDockId && iframeDocks.length > 0)
133-
setSelectedDockId(iframeDocks[0].id)
134-
}, [iframeDocks, selectedDockId])
150+
if (!selectedDockId && renderableDocks.length > 0)
151+
setSelectedDockId(renderableDocks[0].id)
152+
}, [renderableDocks, selectedDockId])
135153

136-
const selectedDock = iframeDocks.find(d => d.id === selectedDockId) ?? null
154+
const selectedDock = renderableDocks.find(d => d.id === selectedDockId) ?? null
155+
const selectedIsIframe = selectedDock ? isIframeDock(selectedDock) : false
156+
157+
// Mount a renderer dock (json-render) into the panel via the client host's
158+
// renderer registry, disposing when the selection changes. Keyed by dock id
159+
// so a live view-state update (its own shared state) doesn't remount.
160+
useEffect(() => {
161+
const host = hostRef.current
162+
const dock = selectedDock
163+
const container = panelRef.current
164+
if (!host || !dock || isIframeDock(dock) || !container)
165+
return
166+
let alive = true
167+
let dispose: (() => void) | undefined
168+
void host.context.renderers.mount(dock, container).then((d) => {
169+
if (alive)
170+
dispose = d
171+
else d()
172+
})
173+
return () => {
174+
alive = false
175+
dispose?.()
176+
}
177+
}, [selectedDockId, selectedIsIframe])
137178

138179
async function ping() {
139180
if (!rpcRef.current)
@@ -169,9 +210,9 @@ export default function Page() {
169210
<aside className="flex flex-col gap-0.5 of-auto border-r border-base bg-secondary p2">
170211
<h2 className="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h2>
171212
<ul className="m0 flex flex-col list-none gap-0.5 p0">
172-
{iframeDocks.length === 0
173-
? <li className="op-mute px2 text-sm">No iframe docks</li>
174-
: iframeDocks.map(dock => (
213+
{renderableDocks.length === 0
214+
? <li className="op-mute px2 text-sm">No docks</li>
215+
: renderableDocks.map(dock => (
175216
<li key={dock.id}>
176217
<button
177218
type="button"
@@ -188,12 +229,19 @@ export default function Page() {
188229
</aside>
189230

190231
<main className="min-w-0 of-hidden bg-secondary">
191-
<iframe
192-
key={selectedDock?.id ?? 'none'}
193-
src={selectedDock?.url ?? 'about:blank'}
194-
title="Selected dock"
195-
className="block h-full w-full border-0 bg-base"
196-
/>
232+
{selectedIsIframe
233+
? (
234+
<iframe
235+
key={selectedDock?.id ?? 'none'}
236+
src={(selectedDock as IframeDock | null)?.url ?? 'about:blank'}
237+
title="Selected dock"
238+
className="block h-full w-full border-0 bg-base"
239+
/>
240+
)
241+
: (
242+
// Renderer docks (json-render) mount here via the client host.
243+
<div key={selectedDock?.id ?? 'none'} ref={panelRef} className="h-full w-full of-auto bg-base p4" />
244+
)}
197245
</main>
198246
</div>
199247

‎examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { homedir } from 'node:os'
55
import process from 'node:process'
66
import { defineHubRpcFunction } from '@devframes/hub'
77
import { createHubContext, mountDevframe } from '@devframes/hub/node'
8+
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
89
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
910
import { startHttpAndWs } from 'devframe/node'
1011
import { getPort } from 'get-port-please'
12+
import { createDashboardView } from 'minimal-json-render/dashboard'
1113
import { dirname, join } from 'pathe'
1214
import demoDevframe from './demo-devframe'
1315
import demoDevframeB from './demo-devframe-b'
@@ -243,6 +245,17 @@ export async function minimalNextDevframeHub(
243245
await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined)
244246
}
245247

248+
// Dogfood the opt-in JSON-render hub integration: author a view on the hub
249+
// context and project it onto a `json-render` dock. The client (app/page.tsx)
250+
// renders it with a mini React registry (registry replacement).
251+
const jsonRenderView = createDashboardView(context)
252+
context.docks.register(toJsonRenderDockEntry(jsonRenderView, {
253+
id: 'minimal-json-render',
254+
title: 'JSON Render',
255+
icon: 'ph:layout-duotone',
256+
category: 'app',
257+
}))
258+
246259
const started = await startHttpAndWs({
247260
context,
248261
host: hostName,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use client'
2+
3+
import type { JsonRenderViewRef, Spec } from '@devframes/json-render'
4+
import type { ComponentRegistry } from '@json-render/react'
5+
import type { ReactNode } from 'react'
6+
import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
7+
import { JSONUIProvider, Renderer } from '@json-render/react'
8+
import { useMemo } from 'react'
9+
import { createRoot } from 'react-dom/client'
10+
import { baseReactRegistry, ERROR_COMPONENT_TYPE } from './registry'
11+
12+
// Built-ins handled inside upstream's ActionProvider — never bridged to RPC.
13+
const RESERVED = new Set(['setState', 'pushState', 'removeState', 'validateForm', 'then', 'catch', 'finally'])
14+
15+
/**
16+
* The unrestricted action bridge: a spec action name is dispatched as an RPC
17+
* call of the same name (per the plan). Upstream tracks per-action loading and
18+
* confirmation; failures rethrow so `onError` handlers fire.
19+
*/
20+
function createActionBridge(rpc: { call: (method: string, ...args: unknown[]) => Promise<unknown> }): Record<string, (params?: Record<string, unknown>) => Promise<unknown>> {
21+
const cache = new Map<string, (params?: Record<string, unknown>) => Promise<unknown>>()
22+
return new Proxy({} as Record<string, (params?: Record<string, unknown>) => Promise<unknown>>, {
23+
has: (_t, p) => typeof p === 'string' && !RESERVED.has(p),
24+
get: (_t, prop) => {
25+
if (typeof prop !== 'string' || RESERVED.has(prop))
26+
return undefined
27+
let fn = cache.get(prop)
28+
if (!fn) {
29+
fn = (params?: Record<string, unknown>) => rpc.call(prop, params)
30+
cache.set(prop, fn)
31+
}
32+
return fn
33+
},
34+
})
35+
}
36+
37+
/**
38+
* Render-time prop validation: swap any element with invalid props for the
39+
* error component, isolating one bad element instead of breaking the view.
40+
*/
41+
function sanitizeSpec(spec: Spec): Spec {
42+
let changed = false
43+
const elements: Spec['elements'] = {}
44+
for (const [key, element] of Object.entries(spec.elements ?? {})) {
45+
const schema = basePropSchemas[element.type as keyof typeof basePropSchemas]
46+
if (schema) {
47+
const result = schema.safeParse(element.props ?? {})
48+
if (!result.success) {
49+
changed = true
50+
const issues = result.error.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
51+
console.warn(`[minimal-next-devframe-hub] invalid props on element "${key}" (${element.type}): ${issues}`)
52+
elements[key] = { ...element, type: ERROR_COMPONENT_TYPE, props: { message: `${element.type}: ${issues}` } }
53+
continue
54+
}
55+
}
56+
elements[key] = element
57+
}
58+
return changed ? { ...spec, elements } : spec
59+
}
60+
61+
interface JsonRenderViewProps {
62+
spec: Spec | null
63+
rpc: { call: (method: string, ...args: unknown[]) => Promise<unknown> }
64+
registry: ComponentRegistry
65+
viewId: string
66+
upstreamVersion?: string
67+
}
68+
69+
function JsonRenderView({ spec, rpc, registry, viewId, upstreamVersion }: JsonRenderViewProps): ReactNode {
70+
const handlers = useMemo(() => createActionBridge(rpc), [rpc])
71+
const effective = useMemo(() => (spec ? sanitizeSpec(spec) : null), [spec])
72+
if (!spec)
73+
return <div className="p4 color-faint text-sm">No view to render.</div>
74+
return (
75+
<JSONUIProvider
76+
// Reset the provider (reseed state) only on identity/version change.
77+
key={`${viewId}::${upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`}
78+
registry={registry}
79+
handlers={handlers}
80+
initialState={spec.state ?? {}}
81+
>
82+
<Renderer spec={effective} registry={registry} />
83+
</JSONUIProvider>
84+
)
85+
}
86+
87+
export interface ReactDockMountOptions {
88+
entry: unknown
89+
container: HTMLElement
90+
91+
context: { rpc: any }
92+
}
93+
94+
/**
95+
* A hub-compatible dock renderer that renders a `json-render` dock with this
96+
* example's mini **React** registry (registry replacement) instead of the Vue
97+
* reference frontend. Mounts a React root into the container the client host
98+
* provides, subscribes to the view's shared state, and disposes cleanly.
99+
*/
100+
export function createReactJsonRenderDockRenderer() {
101+
return async ({ entry, container, context }: ReactDockMountOptions): Promise<{ dispose: () => void }> => {
102+
const view = (entry as { view: JsonRenderViewRef }).view
103+
const rpc = context.rpc
104+
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
105+
const root = createRoot(container)
106+
const render = (): void => {
107+
root.render(
108+
<JsonRenderView
109+
spec={state.value() as Spec | null}
110+
rpc={rpc}
111+
registry={baseReactRegistry}
112+
viewId={view.stateKey}
113+
upstreamVersion={view.upstreamVersion}
114+
/>,
115+
)
116+
}
117+
render()
118+
const off = state.on('updated', render)
119+
return {
120+
dispose() {
121+
off()
122+
root.unmount()
123+
},
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)