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
1 change: 1 addition & 0 deletions docs/examples/minimal-next-devframe-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)**
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
- 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.
- 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.
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`: an iframe dock rendered from a Blob URL, and an interactive `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — its inputs, toggles, and `pushState`/`setState` buttons drive the view's own state (no shared state, nothing synced to the hub), rendered by the same React registry as the server-authored view.

## Run it

Expand Down
1 change: 1 addition & 0 deletions docs/examples/minimal-vite-devframe-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)**
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware.
- 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 })`).
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`: an iframe dock rendered from a Blob URL, and an interactive `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — its inputs, toggles, and `pushState`/`setState` buttons drive the view's own state (no shared state, nothing synced to the hub), rendered by the same renderer as the server-authored view.

## Run it

Expand Down
16 changes: 16 additions & 0 deletions docs/guide/client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ handle.dispose() // remove it

Client-only docks merge into the same `docks.entries` list, group, select, and load their client scripts exactly like server docks — they just never sync to the hub or other viewers. A client dock sharing an id with a server dock overrides it locally. `ctx.docks.update(entry)` replaces a previously registered client dock wholesale. Registering an id that a client dock already owns throws unless you pass `register(entry, true)`.

A client-only dock can render a [JSON-render](./json-render) view the page authors itself. Carry the spec **inline** in the dock's `view` — no shared state, no server round-trip — and register a `json-render` dock. With a `json-render` renderer registered at boot, it renders through the same path as a server-authored view:

```ts
const spec = { /* a DevframeJsonRenderSpec built in the browser */ }

ctx.docks.register({
id: 'client-playground',
title: 'Client Playground',
icon: 'ph:sliders-horizontal-duotone',
type: 'json-render',
view: { spec },
})
```

The `view` field accepts either `{ spec }` (the spec rendered inline) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side). An inline view still runs its own state: `{ $bindState }` inputs and `{ $state }` reads work against the spec's `state`, and the built-in `setState` / `pushState` / `removeState` actions mutate it — so a client-authored view is interactive with no server and no shared state. What `{ spec }` lacks versus `{ stateKey }` is a server-driven update stream.

## Dock client scripts

A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind:
Expand Down
13 changes: 7 additions & 6 deletions docs/guide/json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ its SPA. Connect, read the view's shared state, and render it with
`JsonRenderView`:

```ts
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
import { JsonRenderView } from '@devframes/json-render-ui'
import { connectDevframe } from 'devframe/client'
import { createApp, h, shallowRef } from 'vue'
Expand All @@ -145,7 +144,6 @@ createApp({
render: () => h(JsonRenderView, {
spec: spec.value,
rpc,
upstreamVersion: JSON_RENDER_UPSTREAM_VERSION,
interactive: rpc.connectionMeta.backend !== 'static',
}),
}).mount('#app')
Expand Down Expand Up @@ -205,10 +203,13 @@ const host = await createDevframeClientHost({
const dispose = await host.context.renderers.mount(entry, container)
```

The dock carries only a serializable `JsonRenderViewRef` (`{ stateKey,
upstreamVersion }`) — no functions cross the wire. The client host disposes the
renderer when the dock deactivates. A renderer/upstream-version mismatch logs a
warning rather than blocking.
The dock carries only a serializable `JsonRenderViewRef` — no functions cross
the wire. It comes in two shapes: `{ stateKey }` points the client at a live
shared state to subscribe to (what `createJsonRenderView` produces), while
`{ spec }` embeds the whole spec inline, so a client can synthesize a view in
the browser and render it with no shared state at all (see [client-only
docks](./client-context#client-only-docks)). The client host disposes the
renderer when the dock deactivates.

Both hub example shells dogfood this end to end: the [Vite hub](/examples/minimal-vite-devframe-hub)
registers `@devframes/json-render-ui` (Vue), and the [Next hub](/examples/minimal-next-devframe-hub)
Expand Down
95 changes: 94 additions & 1 deletion examples/minimal-next-devframe-hub/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
DevframeTerminalSession,
DevframeViewIframe,
} from '@devframes/hub/types'
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
Expand Down Expand Up @@ -56,6 +58,79 @@ function createClientNotesUrl(): string {
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
}

// An *interactive* json-render spec synthesized entirely in the browser — the
// client-only counterpart to a server-authored view. Interactivity needs no
// server and no shared state: `{ $bindState }` inputs write straight into the
// view's own `state`, `{ $state }` reads mirror it live, and the buttons use the
// framework's built-in state actions (`pushState` / `setState`) to mutate that
// state — every change re-renders through the mini React registry.
function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec {
return {
root: 'root',
elements: {
root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'hello', 'notes', 'env'] },

head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] },
icon: { type: 'Icon', props: { name: 'ph:sliders-horizontal-duotone', size: 22 }, children: [] },
title: { type: 'Text', props: { text: 'Client Playground', variant: 'heading' }, children: [] },
badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] },

// ── Two-way binding: type a name, see it echoed live; toggle a switch ──
hello: { type: 'Card', props: { title: 'Say hello' }, children: ['helloBody'] },
helloBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greetRow', 'compact'] },
nameInput: { type: 'TextInput', props: { label: 'Your name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] },
greetRow: { type: 'Stack', props: { direction: 'row', gap: 6, align: 'center' }, children: ['greetLabel', 'greetName'] },
greetLabel: { type: 'Text', props: { text: 'Hello,', variant: 'body', color: 'muted' }, children: [] },
greetName: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'body', color: 'primary' }, children: [] },
compact: { type: 'Switch', props: { label: 'Compact mode', value: { $bindState: '/prefs/compact' } }, children: [] },

// ── Actions mutate state → the DataTable re-renders ──
notes: { type: 'Card', props: { title: 'Notes' }, children: ['notesBody'] },
notesBody: { type: 'Stack', props: { gap: 10 }, children: ['draftRow', 'notesTable', 'clearBtn'] },
draftRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'end' }, children: ['draftInput', 'addBtn'] },
draftInput: { type: 'TextInput', props: { label: 'New note', placeholder: 'Write something…', value: { $bindState: '/draft' } }, children: [] },
addBtn: {
type: 'Button',
props: { label: 'Add', variant: 'primary', icon: 'ph:plus' },
// Built-in `pushState`: append the typed draft to /notes, then clear the input.
on: { press: { action: 'pushState', params: { statePath: '/notes', value: { text: { $state: '/draft' } }, clearStatePath: '/draft' } } },
children: [],
},
notesTable: {
type: 'DataTable',
props: { columns: [{ key: 'text', label: 'Note' }], rows: { $state: '/notes' }, height: 160 },
children: [],
},
clearBtn: {
type: 'Button',
props: { label: 'Clear all', variant: 'ghost', icon: 'ph:trash' },
// Built-in `setState`: replace /notes with an empty array.
on: { press: { action: 'setState', params: { statePath: '/notes', value: [] } } },
children: [],
},

env: { type: 'Card', props: { title: 'Environment', collapsible: true, defaultCollapsed: true }, children: ['envTable'] },
envTable: {
type: 'KeyValueTable',
props: {
data: {
clientType,
language: navigator.language,
viewport: `${window.innerWidth}×${window.innerHeight}`,
},
},
children: [],
},
},
state: {
form: { name: '' },
prefs: { compact: false },
draft: '',
notes: [{ text: 'Authored entirely in the browser' }],
},
}
}

/** Render a dock icon, falling back to the title's initial when unmapped. */
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
const cls = iconClass(entry.icon)
Expand Down Expand Up @@ -120,6 +195,23 @@ export default function Page() {
// Patch it in place with the returned handle (the id is immutable).
clientDock.update({ badge: clientHost.context.clientType })

// Register a second client-only dock — this one a *json-render* view the
// page authors itself, the richer sibling of the iframe dock above. Its
// spec is carried **inline** in the dock entry (`view.spec`), so it needs
// no shared state at all: it lives only in this page yet renders — and
// stays fully interactive (inputs, toggles, and buttons that mutate its
// state) — through the same `json-render` dock renderer (the mini React
// registry) as a server-authored view. `force` lets React StrictMode
// re-run this effect safely.
const clientJsonRenderDock = clientHost.context.docks.register<DevframeJsonRenderDockEntry>({
id: 'client-playground',
title: 'Client Playground',
icon: 'ph:sliders-horizontal-duotone',
type: 'json-render',
view: { spec: createClientPlaygroundSpec(clientHost.context.clientType) },
category: 'app',
}, true)

const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
'devframe:docks',
{ initialValue: [] },
Expand Down Expand Up @@ -165,8 +257,9 @@ export default function Page() {

cleanup = () => {
window.clearInterval(interval)
// Remove the client-only dock, then tear down the host.
// Remove the client-only docks, then tear down the host.
clientDock.dispose()
clientJsonRenderDock.dispose()
clientHost.dispose()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { JsonRenderViewRef, Spec } from '@devframes/json-render'
import type { ComponentRegistry } from '@json-render/react'
import type { ReactNode } from 'react'
import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
import { basePropSchemas } from '@devframes/json-render'
import { JSONUIProvider, Renderer } from '@json-render/react'
import { useMemo } from 'react'
import { createRoot } from 'react-dom/client'
Expand Down Expand Up @@ -63,18 +63,17 @@ interface JsonRenderViewProps {
rpc: { call: (method: string, ...args: unknown[]) => Promise<unknown> }
registry: ComponentRegistry
viewId: string
upstreamVersion?: string
}

function JsonRenderView({ spec, rpc, registry, viewId, upstreamVersion }: JsonRenderViewProps): ReactNode {
function JsonRenderView({ spec, rpc, registry, viewId }: JsonRenderViewProps): ReactNode {
const handlers = useMemo(() => createActionBridge(rpc), [rpc])
const effective = useMemo(() => (spec ? sanitizeSpec(spec) : null), [spec])
if (!spec)
return <div className="p4 color-faint text-sm">No view to render.</div>
return (
<JSONUIProvider
// Reset the provider (reseed state) only on identity/version change.
key={`${viewId}::${upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`}
// Reset the provider (reseed state) only on identity change.
key={viewId}
registry={registry}
handlers={handlers}
initialState={spec.state ?? {}}
Expand All @@ -95,22 +94,37 @@ export interface ReactDockMountOptions {
* A hub-compatible dock renderer that renders a `json-render` dock with this
* example's mini **React** registry (registry replacement) instead of the Vue
* reference frontend. Mounts a React root into the container the client host
* provides, subscribes to the view's shared state, and disposes cleanly.
* provides. For a shared-state view it subscribes to the live spec; for an
* inline view (`entry.view.spec`) it renders the embedded spec directly, with
* no shared-state round-trip. Disposes cleanly either way.
*/
export function createReactJsonRenderDockRenderer() {
return async ({ entry, container, context }: ReactDockMountOptions): Promise<{ dispose: () => void }> => {
const view = (entry as { view: JsonRenderViewRef }).view
const rpc = context.rpc
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
const viewId = 'stateKey' in view ? view.stateKey : ((entry as { id?: string }).id ?? 'inline')
const root = createRoot(container)

// Inline view: render the embedded spec once, no shared state involved.
if ('spec' in view) {
root.render(
<JsonRenderView spec={view.spec} rpc={rpc} registry={baseReactRegistry} viewId={viewId} />,
)
return {
dispose() {
root.unmount()
},
}
}

const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
const render = (): void => {
root.render(
<JsonRenderView
spec={state.value() as Spec | null}
rpc={rpc}
registry={baseReactRegistry}
viewId={view.stateKey}
upstreamVersion={view.upstreamVersion}
viewId={viewId}
/>,
)
}
Expand Down
Loading
Loading