|
| 1 | +--- |
| 2 | +outline: deep |
| 3 | +--- |
| 4 | + |
| 5 | +# JSON-Render |
| 6 | + |
| 7 | +JSON-render lets a devframe describe a UI as **data** — a serializable spec of |
| 8 | +components — and have any compatible frontend render it. It is an **opt-in** |
| 9 | +capability: a plain devframe app pulls zero JSON-render dependencies, and the |
| 10 | +hub stays JSON-render-agnostic. You add it by depending on two packages: |
| 11 | + |
| 12 | +- **`@devframes/json-render`** — the framework-neutral protocol layer. It owns |
| 13 | + the spec/catalog types, the base catalog and its per-component prop schemas, |
| 14 | + the serializable view reference, and the node runtime factory. It builds on |
| 15 | + [`@json-render/core`](https://www.npmjs.com/package/@json-render/core) as its |
| 16 | + wire contract and has no Vue or DOM code. |
| 17 | +- **`@devframes/json-render-ui`** — the official reference frontend: a Vue |
| 18 | + renderer implementing the base catalog with [`@antfu/design`](https://github.com/antfu/design). |
| 19 | + Any compatible frontend library can replace it. |
| 20 | + |
| 21 | +A single view authored once renders standalone (the app supplies a frontend |
| 22 | +lib) and inside a hub dock (the hub supplies one), live or static. |
| 23 | + |
| 24 | +## Authoring a view |
| 25 | + |
| 26 | +`createJsonRenderView` augments any devframe context. It registers the spec as |
| 27 | +shared state, validates every element's props against the base catalog at |
| 28 | +ingress, and returns a handle: |
| 29 | + |
| 30 | +```ts |
| 31 | +import { createJsonRenderView } from '@devframes/json-render/node' |
| 32 | + |
| 33 | +export default defineDevframe({ |
| 34 | + // … |
| 35 | + setup(ctx) { |
| 36 | + const view = createJsonRenderView(ctx, { |
| 37 | + id: 'metrics', // stable, unique within the scope |
| 38 | + spec: { |
| 39 | + root: 'root', |
| 40 | + elements: { |
| 41 | + root: { type: 'Card', props: { title: 'Live metrics' }, children: ['count'] }, |
| 42 | + count: { type: 'Text', props: { text: { $state: '/count' } }, children: [] }, |
| 43 | + }, |
| 44 | + state: { count: 0 }, |
| 45 | + }, |
| 46 | + }) |
| 47 | + |
| 48 | + // A structural change replaces the whole spec… |
| 49 | + view.update(nextSpec) |
| 50 | + // …while state travels as JSON-Pointer patches (only the changed path crosses the wire). |
| 51 | + view.patchState([{ op: 'replace', path: '/count', value: 3 }]) |
| 52 | + |
| 53 | + // Unregisters the shared state and its listeners. |
| 54 | + // view.dispose() |
| 55 | + }, |
| 56 | +}) |
| 57 | +``` |
| 58 | + |
| 59 | +The view has a stable, scoped id — `devframe:json-render:<scope>:<id>` — so a |
| 60 | +client keeps its subscription across reconnects. `scope` defaults to the |
| 61 | +context's namespace (from `ctx.scope('my-plugin')`) or `global`. Element props |
| 62 | +are validated at ingress ([DF0038](/errors/DF0038)); a duplicate id |
| 63 | +([DF0039](/errors/DF0039)), a disposed-view use ([DF0040](/errors/DF0040)), and |
| 64 | +a non-JSON-serializable spec ([DF0041](/errors/DF0041)) each raise a diagnostic. |
| 65 | + |
| 66 | +## The base catalog |
| 67 | + |
| 68 | +Catalog v1 ships fourteen components — `Stack`, `Card`, `Text`, `Badge`, |
| 69 | +`Button`, `Icon`, `Divider`, `TextInput`, `Switch`, `KeyValueTable`, |
| 70 | +`DataTable`, `CodeBlock`, `Progress`, `Tree`. A Devframes spec **is** an |
| 71 | +`@json-render/core` `Spec`; the one validation Devframes adds is a per-component |
| 72 | +Zod prop schema (`basePropSchemas`), applied at both boundaries — spec ingress |
| 73 | +(server) and render time (client). Dynamic `$state` / `$bindState` expressions |
| 74 | +are accepted wherever a scalar prop is expected, so a valid binding never fails |
| 75 | +validation. |
| 76 | + |
| 77 | +## Actions and state |
| 78 | + |
| 79 | +- **State** is a JSON-serializable `Record<string, unknown>` addressed by JSON |
| 80 | + Pointer. State updates travel as patches; a structural change replaces the |
| 81 | + whole spec. |
| 82 | +- **Actions** are unrestricted: an element event maps to an action whose name is |
| 83 | + dispatched as an RPC call of the same name. There is no allowlist — a spec |
| 84 | + can invoke any RPC method the client can reach. The reference bridge tracks |
| 85 | + per-action loading state and surfaces RPC failures to the view rather than |
| 86 | + swallowing them. |
| 87 | +- **Reserved built-ins** (`setState`, `pushState`, `removeState`, |
| 88 | + `validateForm`) are handled client-side and are never bridged to RPC. |
| 89 | + |
| 90 | +## Rendering standalone |
| 91 | + |
| 92 | +The app supplies the frontend lib and devframe serves its SPA. Connect, read the |
| 93 | +view's shared state, and render it with `JsonRenderView`: |
| 94 | + |
| 95 | +```ts |
| 96 | +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' |
| 97 | +import { JsonRenderView } from '@devframes/json-render-ui' |
| 98 | +import { connectDevframe } from 'devframe/client' |
| 99 | +import { createApp, h, shallowRef } from 'vue' |
| 100 | + |
| 101 | +const rpc = await connectDevframe() |
| 102 | +const state = await rpc.sharedState.get('devframe:json-render:global:metrics', { initialValue: null }) |
| 103 | +const spec = shallowRef(state.value()) |
| 104 | +state.on('updated', () => { |
| 105 | + spec.value = state.value() |
| 106 | +}) |
| 107 | + |
| 108 | +createApp({ |
| 109 | + render: () => h(JsonRenderView, { |
| 110 | + spec: spec.value, |
| 111 | + rpc, |
| 112 | + upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, |
| 113 | + interactive: rpc.connectionMeta.backend !== 'static', |
| 114 | + }), |
| 115 | +}).mount('#app') |
| 116 | +``` |
| 117 | + |
| 118 | +In a **static** build the spec + state are snapshotted as a read-only render; |
| 119 | +there is no live RPC, so the action bridge reports actions as unavailable and |
| 120 | +`interactive: false` renders a static-output notice. Local state and bindings |
| 121 | +still work. |
| 122 | + |
| 123 | +The reference components author their class strings in `.ts`, so a consuming |
| 124 | +app must opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite), |
| 125 | +and add the lib to the scanned sources when it lives in `node_modules`. |
| 126 | + |
| 127 | +## Rendering inside a hub |
| 128 | + |
| 129 | +The hub is JSON-render-agnostic; its dock union is **open**. The |
| 130 | +`@devframes/json-render/hub` subpath contributes the `json-render` dock type, |
| 131 | +and the hub's client host routes it to a **registered renderer**: |
| 132 | + |
| 133 | +```ts |
| 134 | +// server — register a dock carrying the view's serializable reference |
| 135 | +import { toJsonRenderDockEntry } from '@devframes/json-render/hub' |
| 136 | + |
| 137 | +ctx.docks.register(toJsonRenderDockEntry(view, { |
| 138 | + id: 'metrics', |
| 139 | + title: 'Metrics', |
| 140 | + icon: 'ph:chart-bar-duotone', |
| 141 | +})) |
| 142 | +``` |
| 143 | + |
| 144 | +```ts |
| 145 | +// host page — inject the frontend lib as the renderer for the dock type |
| 146 | +import { createDevframeClientHost } from '@devframes/hub/client' |
| 147 | +import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' |
| 148 | + |
| 149 | +const host = await createDevframeClientHost({ |
| 150 | + renderers: { 'json-render': createJsonRenderDockRenderer() }, |
| 151 | +}) |
| 152 | + |
| 153 | +// the viewer mounts the active dock into a container it owns |
| 154 | +const dispose = await host.context.renderers.mount(entry, container) |
| 155 | +``` |
| 156 | + |
| 157 | +The dock carries only a serializable `JsonRenderViewRef` (`{ stateKey, |
| 158 | +upstreamVersion }`) — no functions cross the wire. The client host disposes the |
| 159 | +renderer when the dock deactivates. A renderer/upstream-version mismatch logs a |
| 160 | +warning rather than blocking. |
| 161 | + |
| 162 | +## Swapping the frontend |
| 163 | + |
| 164 | +A third party replaces the whole registry — pass a custom `registry` to |
| 165 | +`createRenderer({ registry })` or `createJsonRenderDockRenderer({ registry })`. |
| 166 | +`@devframes/json-render-ui` is the reference implementation, not a hard |
| 167 | +dependency of the protocol; the hub acquires no Vue. |
| 168 | + |
| 169 | +See the [`minimal-json-render` example](/examples/minimal-json-render) for a |
| 170 | +runnable end-to-end app. |
0 commit comments