Skip to content

JSON-Render

JSON-render lets a devframe describe a UI as data — a serializable spec of components — and have any compatible frontend render it. It is an opt-in capability: a plain devframe app pulls zero JSON-render dependencies, and the hub stays JSON-render-agnostic. You add it by depending on two packages:

  • @devframes/json-render — the framework-neutral protocol layer. It owns the spec/catalog types, the base catalog and its per-component prop schemas, the serializable view reference, and the node runtime factory. It builds on @json-render/core as its wire contract and has no Vue or DOM code.
  • @devframes/json-render-ui — the official reference frontend: a Vue renderer implementing the base catalog with @antfu/design. Any compatible frontend library can replace it.

A single view authored once renders standalone (the app supplies a frontend lib) and inside a hub dock (the hub supplies one), live or static.

Authoring a view

createJsonRenderView augments any devframe context. It registers the spec as shared state, validates every element's props against the base catalog at ingress, and returns a handle:

ts
import { createJsonRenderView } from '@devframes/json-render/node'

export default defineDevframe({
  // …
  setup(ctx) {
    const view = createJsonRenderView(ctx, {
      id: 'metrics', // stable, unique within the scope
      spec: {
        root: 'root',
        elements: {
          root: { type: 'Card', props: { title: 'Live metrics' }, children: ['count'] },
          count: { type: 'Text', props: { text: { $state: '/count' } }, children: [] },
        },
        state: { count: 0 },
      },
    })

    // A structural change replaces the whole spec…
    view.update(nextSpec)
    // …while state travels as JSON-Pointer patches (only the changed path crosses the wire).
    view.patchState([{ op: 'replace', path: '/count', value: 3 }])

    // Unregisters the shared state and its listeners.
    // view.dispose()
  },
})

The view has a stable, scoped id — devframe:json-render:<scope>:<id> — so a client keeps its subscription across reconnects. scope defaults to the context's namespace (from ctx.scope('my-plugin')) or global. Element props are validated at ingress (DF0038); a duplicate id (DF0039), a disposed-view use (DF0040), and a non-JSON-serializable spec (DF0041) each raise a diagnostic.

The base catalog

Catalog v1 ships fourteen components — Stack, Card, Text, Badge, Button, Icon, Divider, TextInput, Switch, KeyValueTable, DataTable, CodeBlock, Progress, Tree. A Devframes spec is an @json-render/core Spec; the one validation Devframes adds is a per-component Zod prop schema (basePropSchemas), applied at both boundaries — spec ingress (server) and render time (client). Dynamic $state / $bindState expressions are accepted wherever a scalar prop is expected, so a valid binding never fails validation.

Actions and state

  • State is a JSON-serializable Record<string, unknown> addressed by JSON Pointer. State updates travel as patches; a structural change replaces the whole spec.
  • Actions are unrestricted: an element event maps to an action whose name is dispatched as an RPC call of the same name. There is no allowlist — a spec can invoke any RPC method the client can reach. The reference bridge tracks per-action loading state and surfaces RPC failures to the view rather than swallowing them.
  • Reserved built-ins (setState, pushState, removeState, validateForm) are handled client-side and are never bridged to RPC.

Rendering standalone

The app supplies the frontend lib and devframe serves 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'

const rpc = await connectDevframe()
const state = await rpc.sharedState.get('devframe:json-render:global:metrics', { initialValue: null })
const spec = shallowRef(state.value())
state.on('updated', () => {
  spec.value = state.value()
})

createApp({
  render: () => h(JsonRenderView, {
    spec: spec.value,
    rpc,
    upstreamVersion: JSON_RENDER_UPSTREAM_VERSION,
    interactive: rpc.connectionMeta.backend !== 'static',
  }),
}).mount('#app')

In a static build the spec + state are snapshotted as a read-only render; there is no live RPC, so the action bridge reports actions as unavailable and interactive: false renders a static-output notice. Local state and bindings still work.

Consuming the reference frontend

@devframes/json-render-ui wraps @antfu/design's Vue components directly (ActionButton, DisplayBadge, LayoutCard, FormTextInput, FormSwitch, DisplayProgressBar, DisplayKeyValue, and DisplayIconifyRemoteIcon for fully dynamic, currentColor-inheriting icons), so it looks and behaves like the rest of the devframe surfaces. A few catalog components stay bespoke where @antfu/design has no matching primitive — Stack, Text, CodeBlock, the value-tree Tree, and the row-clickable/loadable DataTable.

A consuming Vite app therefore:

  • installs @antfu/design (a peer dependency) and imports @antfu/design/styles.css;
  • excludes it from dep pre-bundling so @vitejs/plugin-vue compiles its SFCs — optimizeDeps: { exclude: ['@antfu/design'] };
  • composes the shared UnoCSS preset (presetAnthonyDesign) and safelists the runtime-selected badge colors the base catalog can emit — safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue'].

Rendering inside a hub

The hub is JSON-render-agnostic; its dock union is open. The @devframes/json-render/hub subpath contributes the json-render dock type, and the hub's client host routes it to a registered renderer:

ts
// server — register a dock carrying the view's serializable reference
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'

ctx.docks.register(toJsonRenderDockEntry(view, {
  id: 'metrics',
  title: 'Metrics',
  icon: 'ph:chart-bar-duotone',
}))
ts
// host page — inject the frontend lib as the renderer for the dock type
import { createDevframeClientHost } from '@devframes/hub/client'
import { createJsonRenderDockRenderer } from '@devframes/json-render-ui'

const host = await createDevframeClientHost({
  renderers: { 'json-render': createJsonRenderDockRenderer() },
})

// the viewer mounts the active dock into a container it owns
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.

Both hub example shells dogfood this end to end: the Vite hub registers @devframes/json-render-ui (Vue), and the Next hub registers a small in-example React registry — the same dock, two frontends.

Swapping the frontend

A third party replaces the whole registry — pass a custom registry to createRenderer({ registry }) or createJsonRenderDockRenderer({ registry }). @devframes/json-render-ui is the reference implementation, not a hard dependency of the protocol; the hub acquires no Vue.

See the minimal-json-render example for a runnable end-to-end app.

Released under the MIT License.