Skip to content

Commit bb69bde

Browse files
authored
feat(json-render): add typed specs and custom validation (#273)
1 parent 9916f10 commit bb69bde

14 files changed

Lines changed: 273 additions & 34 deletions

File tree

docs/errors/DF0073.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0073: JSON-Render Spec Does Not Match Its Schema
6+
7+
## Message
8+
9+
> JSON-render view "`{id}`" does not match its configured schema: `{issues}`
10+
11+
## Cause
12+
13+
The spec failed the optional Standard Schema supplied when the JSON-render view was created. The same schema guards the initial spec and every update.
14+
15+
## Fix
16+
17+
Match the authored spec to the configured schema before creating or updating the view.
18+
19+
## Source
20+
21+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`validateSpec()` throws this before shared state changes.

docs/errors/DF0074.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0074: JSON-Render Schema Is Asynchronous
6+
7+
## Message
8+
9+
> JSON-render view "`{id}`" uses an asynchronous Standard Schema.
10+
11+
## Cause
12+
13+
JSON-render view creation and updates are synchronous, while the configured Standard Schema returned a promise.
14+
15+
## Fix
16+
17+
Use a synchronous Standard Schema for JSON-render specs.
18+
19+
## Source
20+
21+
- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts)`validateSpec()` rejects promise-returning validators.

docs/errors/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface.
4242
| [DF0031](./DF0031) | error | Write to Closed Stream |
4343
| [DF0032](./DF0032) | error | Streaming Channel Already Registered |
4444
| [DF0033](./DF0033) | warn | Dev RPC Bridge Failed to Start |
45+
| [DF0073](./DF0073) | error | JSON-Render Spec Does Not Match Its Schema |
46+
| [DF0074](./DF0074) | error | JSON-Render Schema Is Asynchronous |

packages/json-render/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
},
4747
"dependencies": {
4848
"@json-render/core": "catalog:deps",
49+
"@standard-schema/spec": "catalog:deps",
4950
"zod": "catalog:deps"
5051
},
5152
"devDependencies": {

packages/json-render/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export {
3535
} from './prop-schemas'
3636

3737
// ── Devframes-facing type names ──────────────────────────────────────────
38-
export type { DevframeJsonRenderSpec, JsonRenderView } from './types'
38+
export type { CatalogUIElement, DevframeJsonRenderSpec, JsonRenderView } from './types'
3939
// ── View index (frontend view discovery) ─────────────────────────────────
4040
export { JSON_RENDER_INDEX_KEY } from './view-index'
4141

packages/json-render/src/node/create-view.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { StandardSchemaV1 } from '@standard-schema/spec'
12
import type { DevframeNodeContext, DevframeScopedNodeContext } from 'devframe'
23
import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
34
import type { DevframeJsonRenderSpec, JsonRenderStatePatch, JsonRenderView } from '../types'
@@ -8,15 +9,17 @@ import { JSON_RENDER_INDEX_KEY } from '../view-index'
89
import { diagnostics } from './diagnostics'
910

1011
/** Options for {@link createJsonRenderView}. */
11-
export interface CreateJsonRenderViewOptions {
12+
export interface CreateJsonRenderViewOptions<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
1213
/**
1314
* Stable, author-supplied id, unique within the view's scope. Forms the
1415
* shared-state key `devframe:json-render:<scope>:<id>` and never changes
1516
* across updates, so a client keeps its subscription across reconnects.
1617
*/
1718
id: string
1819
/** The initial spec. */
19-
spec: DevframeJsonRenderSpec
20+
spec: SpecType
21+
/** A replacement Standard Schema validator, or `false` to disable validation. */
22+
schema?: StandardSchemaV1 | false
2023
/**
2124
* Override the scope segment of the view's stable id. Defaults to the
2225
* context's namespace when created from a scoped context, otherwise
@@ -98,9 +101,43 @@ function validateElementProps(id: string, spec: DevframeJsonRenderSpec): void {
98101
}
99102
}
100103

104+
function formatStandardSchemaIssues(issues: readonly StandardSchemaV1.Issue[]): string {
105+
return issues
106+
.map((issue) => {
107+
const path = issue.path
108+
?.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment))
109+
.join('.')
110+
return `${path || '(root)'}: ${issue.message}`
111+
})
112+
.join('; ')
113+
}
114+
115+
function isPromise<Result>(value: Result | Promise<Result>): value is Promise<Result> {
116+
return typeof (value as Promise<Result>).then === 'function'
117+
}
118+
119+
function validateSpec(
120+
id: string,
121+
spec: DevframeJsonRenderSpec,
122+
schema: StandardSchemaV1 | false | undefined,
123+
): void {
124+
if (schema === false)
125+
return
126+
if (!schema) {
127+
validateElementProps(id, spec)
128+
return
129+
}
130+
131+
const result = schema['~standard'].validate(spec)
132+
if (isPromise(result))
133+
throw diagnostics.DF0074({ id })
134+
if (result.issues)
135+
throw diagnostics.DF0073({ id, issues: formatStandardSchemaIssues(result.issues) })
136+
}
137+
101138
// Ensure the spec always carries a `state` object so JSON-Pointer patches
102139
// into `/state/...` have a container to target.
103-
function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec {
140+
function normalizeSpec<SpecType extends DevframeJsonRenderSpec>(spec: SpecType): SpecType {
104141
return spec.state ? spec : { ...spec, state: {} }
105142
}
106143

@@ -119,10 +156,10 @@ function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec {
119156
* view.dispose()
120157
* ```
121158
*/
122-
export function createJsonRenderView(
159+
export function createJsonRenderView<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec>(
123160
ctx: AnyContext,
124-
options: CreateJsonRenderViewOptions,
125-
): JsonRenderView {
161+
options: CreateJsonRenderViewOptions<SpecType>,
162+
): JsonRenderView<SpecType> {
126163
const scoped = isScoped(ctx)
127164
const baseCtx = scoped ? ctx.base : ctx
128165
const scope = options.scope ?? (scoped ? ctx.namespace : 'global')
@@ -135,10 +172,10 @@ export function createJsonRenderView(
135172
throw diagnostics.DF0039({ id, scope })
136173

137174
const initial = normalizeSpec(options.spec)
138-
validateElementProps(id, initial)
175+
validateSpec(id, initial, options.schema)
139176
assertJsonSerializable(id, initial)
140177

141-
const state: SharedState<DevframeJsonRenderSpec> = createSharedState({
178+
const state: SharedState<SpecType> = createSharedState({
142179
initialValue: initial,
143180
enablePatches: true,
144181
})
@@ -165,11 +202,11 @@ export function createJsonRenderView(
165202
id,
166203
title,
167204
ref: { stateKey },
168-
value: () => state.value() as DevframeJsonRenderSpec,
205+
value: () => state.value() as SpecType,
169206
update(spec) {
170207
assertLive()
171208
const next = normalizeSpec(spec)
172-
validateElementProps(id, next)
209+
validateSpec(id, next, options.schema)
173210
assertJsonSerializable(id, next)
174211
state.mutate(() => next)
175212
},

packages/json-render/src/node/diagnostics.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { defineDiagnostics } from 'devframe/utils/nostics'
22

3-
// `@devframes/json-render` protocol/runtime diagnostics. These share the
4-
// `DF` prefix and live in the devframe core range (next free after the
5-
// current highest `DF00xx`, DF0037). Browser-only render failures keep
6-
// `console.*` in the UI package.
3+
// `@devframes/json-render` protocol/runtime diagnostics share the `DF`
4+
// prefix and use the next globally available core codes. Browser-only render
5+
// failures keep `console.*` in the UI package.
76
export const diagnostics = defineDiagnostics({
87
docsBase: 'https://devfra.me/errors',
98
codes: {
@@ -27,5 +26,15 @@ export const diagnostics = defineDiagnostics({
2726
`JSON-render view "${p.id}" spec is not JSON-serializable: ${p.reason}`,
2827
fix: 'Specs and state travel as strict JSON — remove functions, symbols, class instances, Map/Set, or circular references.',
2928
},
29+
DF0073: {
30+
why: (p: { id: string, issues: string }) =>
31+
`JSON-render view "${p.id}" does not match its configured schema: ${p.issues}`,
32+
fix: 'Match the authored spec to the Standard Schema passed to `createJsonRenderView`.',
33+
},
34+
DF0074: {
35+
why: (p: { id: string }) =>
36+
`JSON-render view "${p.id}" uses an asynchronous Standard Schema.`,
37+
fix: 'Use a synchronous Standard Schema so initial creation and updates remain synchronous.',
38+
},
3039
},
3140
})

packages/json-render/src/types.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
1-
import type { Spec } from '@json-render/core'
1+
import type {
2+
Catalog,
3+
InferCatalogComponents,
4+
InferComponentProps,
5+
Spec,
6+
UIElement,
7+
} from '@json-render/core'
28
import type { JsonRenderViewStateRef } from './view-ref'
39

410
/**
511
* A Devframes JSON-render spec **is** an `@json-render/core` `Spec`: a flat
612
* `root` key, an `elements` map, and optional initial `state`. This alias is
713
* the Devframes-facing name; it does not add or remove fields.
814
*/
9-
export type DevframeJsonRenderSpec = Spec
15+
export type DevframeJsonRenderSpec<Element extends UIElement = UIElement> = Omit<Spec, 'elements'> & {
16+
elements: Record<string, Element>
17+
}
18+
19+
/** Derive a discriminated element union from every component in a catalog. */
20+
export type CatalogUIElement<CatalogType extends Catalog> = {
21+
[ComponentName in keyof InferCatalogComponents<CatalogType> & string]: UIElement<
22+
ComponentName,
23+
InferComponentProps<CatalogType, ComponentName>
24+
>
25+
}[keyof InferCatalogComponents<CatalogType> & string]
1026

1127
/**
1228
* A single JSON-Pointer patch to a view's `state` model. `path` is an
@@ -27,23 +43,23 @@ export interface JsonRenderStatePatch {
2743
* serializable {@link JsonRenderViewStateRef} that a hub dock (or any client
2844
* transport) uses to locate it.
2945
*/
30-
export interface JsonRenderView {
46+
export interface JsonRenderView<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
3147
/** Author-supplied stable id, unique within the view's scope. */
3248
readonly id: string
3349
/** Human-facing label published in the view index (defaults to `id`). */
3450
readonly title: string
3551
/** The serializable reference clients subscribe through. */
3652
readonly ref: JsonRenderViewStateRef
3753
/** Replace the entire spec (a structural change replaces the whole spec). */
38-
update: (spec: DevframeJsonRenderSpec) => void
54+
update: (spec: SpecType) => void
3955
/**
4056
* Apply JSON-Pointer patches to the view's `state`. Travels as a
4157
* shared-state patch (not a whole-spec snapshot), so only the changed
4258
* paths cross the wire.
4359
*/
4460
patchState: (patches: JsonRenderStatePatch[]) => void
4561
/** Read the current spec (immutable). */
46-
value: () => DevframeJsonRenderSpec
62+
value: () => SpecType
4763
/** Unregister the shared state and its listeners. */
4864
dispose: () => void
4965
}

packages/json-render/src/view-ref.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export interface JsonRenderViewStateRef {
1818
* rendered as-is (static: local state and bindings still work, but there is no
1919
* server-driven live update stream).
2020
*/
21-
export interface JsonRenderViewInlineRef {
21+
export interface JsonRenderViewInlineRef<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
2222
/** The full spec, carried in the reference itself. */
23-
spec: DevframeJsonRenderSpec
23+
spec: SpecType
2424
}
2525

2626
/**
@@ -30,4 +30,5 @@ export interface JsonRenderViewInlineRef {
3030
* the client subscribes through, or an {@link JsonRenderViewInlineRef.spec
3131
* inline spec} rendered directly.
3232
*/
33-
export type JsonRenderViewRef = JsonRenderViewStateRef | JsonRenderViewInlineRef
33+
export type JsonRenderViewRef<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec>
34+
= JsonRenderViewStateRef | JsonRenderViewInlineRef<SpecType>

packages/json-render/test/catalog.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it } from 'vitest'
1+
import type { CatalogUIElement, DevframeJsonRenderSpec, InferComponentProps } from '../src/index'
2+
import { describe, expect, expectTypeOf, it } from 'vitest'
23
import { baseCatalog, baseComponentNames, basePropSchemas } from '../src/index'
34

45
describe('base catalog', () => {
@@ -54,3 +55,18 @@ describe('per-component prop validation', () => {
5455
expect(basePropSchemas.Switch.safeParse({ value: { $state: '/enabled' } }).success).toBe(true)
5556
})
5657
})
58+
59+
describe('catalog-derived element typing', () => {
60+
it('narrows props when the element type is checked', () => {
61+
type BaseCatalogElement = CatalogUIElement<typeof baseCatalog>
62+
const assertNarrowing = (element: BaseCatalogElement): void => {
63+
if (element.type === 'Button')
64+
expectTypeOf(element.props).toEqualTypeOf<InferComponentProps<typeof baseCatalog, 'Button'>>()
65+
if (element.type === 'Text')
66+
expectTypeOf(element.props).toEqualTypeOf<InferComponentProps<typeof baseCatalog, 'Text'>>()
67+
}
68+
69+
expectTypeOf<DevframeJsonRenderSpec<BaseCatalogElement>['elements'][string]>().toEqualTypeOf<BaseCatalogElement>()
70+
expectTypeOf(assertNarrowing).toBeFunction()
71+
})
72+
})

0 commit comments

Comments
 (0)