Skip to content

Commit 9a9595a

Browse files
committed
feat: add @devframes/json-render and @devframes/json-render-ui packages
Introduce JSON-render as an opt-in capability a devframe app adds, rather than a cost the hub or a plain app pays. - @devframes/json-render: framework-neutral protocol layer — base catalog + per-component Zod prop schemas, serializable JsonRenderViewRef, curated ./core re-exports of @json-render/core, and a ./node createJsonRenderView runtime factory (scoped stable ids, patch-enabled shared state, JSON-Pointer state patches, ingress prop + serializability validation, disposal). A ./hub subpath contributes the json-render dock type to the hub's open dock union without making the hub depend on json-render. - @devframes/json-render-ui: official Vue reference frontend on @antfu/design — the fourteen base components, base registry, renderer shell with reset semantics, an unrestricted action bridge that tracks per-action loading and surfaces failures, render-time prop isolation, a hub dock renderer, and Storybook coverage. - Hub: open the dock union via an augmentable registry and drop the closed json-render variant, createJsonRenderer, and the hand-written json-render types; add registerRenderer routing (with dispose-on-deactivation) to the client host. - devframe core: add RpcSharedStateHost.delete, load immer patches for patch-enabled shared state, and remove stale createJsonRenderer references. - Docs: error pages DF0038-DF0041. tsnapi snapshots + exports tests updated. Created with the help of an agent.
1 parent 47d81f6 commit 9a9595a

79 files changed

Lines changed: 3471 additions & 143 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export const alias = {
4646
'@devframes/hub': r('hub/src/index.ts'),
4747
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
4848
'@devframes/nuxt': r('nuxt/src/index.ts'),
49+
'@devframes/json-render/core': r('json-render/src/core.ts'),
50+
'@devframes/json-render/hub': r('json-render/src/hub.ts'),
51+
'@devframes/json-render/node': r('json-render/src/node/index.ts'),
52+
'@devframes/json-render': r('json-render/src/index.ts'),
53+
'@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'),
54+
'@devframes/json-render-ui': r('json-render-ui/src/index.ts'),
4955
'@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'),
5056
'@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'),
5157
'@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'),

docs/errors/DF0038.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0038: Invalid JSON-Render Element Props
6+
7+
## Message
8+
9+
> JSON-render view "`{id}`" received invalid props on element "`{key}`": `{issues}`
10+
11+
## Cause
12+
13+
`@devframes/json-render` validates every element's props against the base catalog's per-component Zod schema at spec ingress (`createJsonRenderView` / `view.update`). Upstream `@json-render/core` only checks component *names*, so this per-component prop check is the one validation Devframes adds. An element whose props don't match its component's schema is rejected here rather than failing silently at render.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad — `variant` is not one of the Button variants
19+
createJsonRenderView(ctx, {
20+
id: 'toolbar',
21+
spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'nope' }, children: [] } } },
22+
})
23+
24+
// ✓ Good
25+
createJsonRenderView(ctx, {
26+
id: 'toolbar',
27+
spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'primary', label: 'Save' }, children: [] } } },
28+
})
29+
```
30+
31+
## Fix
32+
33+
Match the element props to the base catalog's prop schema for that component. Dynamic `$state` / `$bindState` expressions are accepted wherever a scalar prop is expected, so a valid binding never triggers this.
34+
35+
## Source
36+
37+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`validateElementProps()` throws this at ingress and on `update`.

docs/errors/DF0039.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0039: Duplicate JSON-Render View
6+
7+
## Message
8+
9+
> A JSON-render view with id "`{id}`" already exists in scope "`{scope}`".
10+
11+
## Cause
12+
13+
Each JSON-render view has a stable, author-supplied id that forms its shared-state key `devframe:json-render:<scope>:<id>`. Ids must be unique within a scope so the client keeps a stable subscription across reconnects. Creating a second view with the same id in the same scope throws instead of clobbering the first.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad — same id twice in the same scope
19+
createJsonRenderView(ctx, { id: 'metrics', spec })
20+
createJsonRenderView(ctx, { id: 'metrics', spec }) // DF0039
21+
22+
// ✓ Good — dispose the previous view first, or use a distinct id
23+
const view = createJsonRenderView(ctx, { id: 'metrics', spec })
24+
view.dispose()
25+
createJsonRenderView(ctx, { id: 'metrics', spec })
26+
```
27+
28+
## Fix
29+
30+
Give each view a stable id unique within its scope, or dispose the previous view before recreating it. Scope defaults to the context namespace (or `global`); pass `scope` to isolate ids explicitly.
31+
32+
## Source
33+
34+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`createJsonRenderView()` throws this when the scoped id is already live.

docs/errors/DF0040.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0040: JSON-Render View Used After Disposal
6+
7+
## Message
8+
9+
> JSON-render view "`{id}`" was used after it was disposed.
10+
11+
## Cause
12+
13+
`view.dispose()` unregisters the view's shared state and its broadcast listeners. Calling `update` or `patchState` on a disposed handle is a lifecycle bug — the shared state it targeted no longer exists.
14+
15+
## Example
16+
17+
```ts
18+
const view = createJsonRenderView(ctx, { id: 'metrics', spec })
19+
view.dispose()
20+
view.update(nextSpec) // DF0040
21+
```
22+
23+
## Fix
24+
25+
Create a fresh view with `createJsonRenderView` instead of reusing a disposed handle. The id is free to reuse once disposed.
26+
27+
## Source
28+
29+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`update` / `patchState` throw this after `dispose()`.

docs/errors/DF0041.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0041: JSON-Render Spec Is Not JSON-Serializable
6+
7+
## Message
8+
9+
> JSON-render view "`{id}`" spec is not JSON-serializable: `{reason}`
10+
11+
## Cause
12+
13+
Specs and state travel across the RPC / static boundary as strict JSON. A spec containing functions, symbols, class instances, `Map`/`Set`, or circular references cannot cross that boundary, so it is rejected at ingress.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad — circular reference
19+
const spec: any = { root: 'a', elements: {} }
20+
spec.self = spec
21+
createJsonRenderView(ctx, { id: 'x', spec }) // DF0041
22+
23+
// ✓ Good — plain JSON data
24+
createJsonRenderView(ctx, { id: 'x', spec: { root: 'a', elements: { a: { type: 'Text', props: { text: 'hi' }, children: [] } } } })
25+
```
26+
27+
## Fix
28+
29+
Keep specs and state strict JSON — remove functions, symbols, class instances, `Map`/`Set`, or circular references.
30+
31+
## Source
32+
33+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`assertJsonSerializable()` throws this at ingress and on `update`.

packages/devframe/src/client/rpc-shared-state.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createSharedState } from 'devframe/utils/shared-state'
55

66
export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost {
77
const sharedState = new Map<string, SharedState<any>>()
8+
const stateDisposers = new Map<string, () => void>()
89
const initialValues = new Map<string, any>()
910
const keyAddedListeners = new Set<(key: string) => void>()
1011
const isStaticBackend = rpc.connectionMeta.backend === 'static'
@@ -68,6 +69,14 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
6869
keyAddedListeners.delete(fn)
6970
}
7071
},
72+
delete(key) {
73+
const dispose = stateDisposers.get(key)
74+
stateDisposers.delete(key)
75+
const existed = sharedState.delete(key)
76+
initialValues.delete(key)
77+
dispose?.()
78+
return existed
79+
},
7180
get: async <T extends object>(key: string, options?: RpcSharedStateGetOptions<T>) => {
7281
if (options?.initialValue !== undefined) {
7382
initialValues.set(key, options.initialValue)
@@ -97,7 +106,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
97106
.catch((error) => {
98107
console.error('Error getting server state', error)
99108
})
100-
registerSharedState(key, state)
109+
stateDisposers.set(key, registerSharedState(key, state))
101110
return state
102111
}
103112
else {
@@ -106,7 +115,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
106115
sharedState.set(key, state)
107116
for (const fn of keyAddedListeners)
108117
fn(key)
109-
registerSharedState(key, state)
118+
stateDisposers.set(key, registerSharedState(key, state))
110119
return state
111120
}
112121
}

packages/devframe/src/node/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export interface CreateHostContextOptions {
2828
* Wires the RPC host, view (HTTP file-serving) host, diagnostics, and
2929
* agent subsystems. Host adapters can wrap this to augment `ctx` with
3030
* extra surfaces — for example, `@vitejs/devtools-kit`'s
31-
* `createKitContext` attaches `docks`, `terminals`, `messages`,
32-
* `commands`, and `createJsonRenderer` when mounted into Vite DevTools.
31+
* `createKitContext` attaches `docks`, `terminals`, `messages`, and
32+
* `commands` when mounted into Vite DevTools.
3333
*/
3434
export async function createHostContext(options: CreateHostContextOptions): Promise<DevframeNodeContext> {
3535
const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options

packages/devframe/src/node/rpc-shared-state.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function createRpcSharedStateServerHost(
1111
rpc: RpcFunctionsHost,
1212
): RpcSharedStateHost {
1313
const sharedState = new Map<string, SharedState<any>>()
14+
const stateDisposers = new Map<string, () => void>()
1415
const keyAddedListeners = new Set<(key: string) => void>()
1516

1617
function registerSharedState<T extends object>(key: string, state: SharedState<T>) {
@@ -57,7 +58,7 @@ export function createRpcSharedStateServerHost(
5758
initialValue: options.initialValue as T,
5859
enablePatches: false,
5960
})
60-
registerSharedState(key, state)
61+
stateDisposers.set(key, registerSharedState(key, state))
6162
sharedState.set(key, state)
6263
for (const fn of keyAddedListeners)
6364
fn(key)
@@ -72,6 +73,15 @@ export function createRpcSharedStateServerHost(
7273
keyAddedListeners.delete(fn)
7374
}
7475
},
76+
delete(key) {
77+
const dispose = stateDisposers.get(key)
78+
if (!dispose)
79+
return false
80+
dispose()
81+
stateDisposers.delete(key)
82+
sharedState.delete(key)
83+
return true
84+
},
7585
}
7686

7787
// Wire methods that the client-side `client/rpc-shared-state.ts`

packages/devframe/src/types/context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export interface DevframeCapabilities {
1515
* agent + the view-host (HTTP file-serving). Host adapters can wrap this
1616
* to add their own surfaces; for example, `@vitejs/devtools-kit`'s
1717
* `createKitContext` adds `docks`, `terminals`, `messages`, and
18-
* `commands` when mounted into Vite DevTools.
18+
* `commands` when mounted into Vite DevTools. JSON rendering is an opt-in
19+
* integration (`@devframes/json-render`) layered on top, not part of this
20+
* core surface.
1921
*/
2022
export interface DevframeNodeContext {
2123
readonly workspaceRoot: string

packages/devframe/src/types/rpc.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ export interface RpcSharedStateHost {
8181
* as dynamic resources.
8282
*/
8383
onKeyAdded: (fn: (key: string) => void) => () => void
84+
/**
85+
* Unregister a shared state and drop its broadcast listeners. Returns
86+
* `true` when a state was removed, `false` when the key was unknown.
87+
* Used by short-lived states (e.g. a disposed JSON-render view) to avoid
88+
* leaking listeners and lingering entries for the context lifetime.
89+
*/
90+
delete: (key: string) => boolean
8491
}
8592

8693
/**

0 commit comments

Comments
 (0)