From 6c5cff33320a1cf189e1d7d8baab0891399753c8 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 20 Aug 2026 17:44:33 +0200 Subject: [PATCH 1/2] feat(json-render): add typed specs and custom validation --- docs/errors/DF0073.md | 21 ++++ docs/errors/DF0074.md | 21 ++++ docs/errors/index.md | 2 + packages/json-render/package.json | 1 + packages/json-render/src/index.ts | 2 +- packages/json-render/src/node/create-view.ts | 57 +++++++++-- packages/json-render/src/node/diagnostics.ts | 17 +++- packages/json-render/src/types.ts | 26 ++++- packages/json-render/src/view-ref.ts | 7 +- packages/json-render/test/catalog.test.ts | 18 +++- packages/json-render/test/create-view.test.ts | 95 +++++++++++++++++++ pnpm-lock.yaml | 3 + 12 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 docs/errors/DF0073.md create mode 100644 docs/errors/DF0074.md diff --git a/docs/errors/DF0073.md b/docs/errors/DF0073.md new file mode 100644 index 00000000..74cb1f26 --- /dev/null +++ b/docs/errors/DF0073.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0073: JSON-Render Spec Does Not Match Its Schema + +## Message + +> JSON-render view "`{id}`" does not match its configured schema: `{issues}` + +## Cause + +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. + +## Fix + +Match the authored spec to the configured schema before creating or updating the view. + +## Source + +- [`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. diff --git a/docs/errors/DF0074.md b/docs/errors/DF0074.md new file mode 100644 index 00000000..38fd0935 --- /dev/null +++ b/docs/errors/DF0074.md @@ -0,0 +1,21 @@ +--- +outline: deep +--- + +# DF0074: JSON-Render Schema Is Asynchronous + +## Message + +> JSON-render view "`{id}`" uses an asynchronous Standard Schema. + +## Cause + +JSON-render view creation and updates are synchronous, while the configured Standard Schema returned a promise. + +## Fix + +Use a synchronous Standard Schema for JSON-render specs. + +## Source + +- [`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. diff --git a/docs/errors/index.md b/docs/errors/index.md index 1809e463..92f3efdb 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -42,3 +42,5 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface. | [DF0031](./DF0031) | error | Write to Closed Stream | | [DF0032](./DF0032) | error | Streaming Channel Already Registered | | [DF0033](./DF0033) | warn | Dev RPC Bridge Failed to Start | +| [DF0073](./DF0073) | error | JSON-Render Spec Does Not Match Its Schema | +| [DF0074](./DF0074) | error | JSON-Render Schema Is Asynchronous | diff --git a/packages/json-render/package.json b/packages/json-render/package.json index 101d1483..e6ec5cde 100644 --- a/packages/json-render/package.json +++ b/packages/json-render/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@json-render/core": "catalog:deps", + "@standard-schema/spec": "catalog:deps", "zod": "catalog:deps" }, "devDependencies": { diff --git a/packages/json-render/src/index.ts b/packages/json-render/src/index.ts index 14472337..42b99a40 100644 --- a/packages/json-render/src/index.ts +++ b/packages/json-render/src/index.ts @@ -35,7 +35,7 @@ export { } from './prop-schemas' // ── Devframes-facing type names ────────────────────────────────────────── -export type { DevframeJsonRenderSpec, JsonRenderView } from './types' +export type { CatalogUIElement, DevframeJsonRenderSpec, JsonRenderView } from './types' // ── View index (frontend view discovery) ───────────────────────────────── export { JSON_RENDER_INDEX_KEY } from './view-index' diff --git a/packages/json-render/src/node/create-view.ts b/packages/json-render/src/node/create-view.ts index 2751238e..ea17ba06 100644 --- a/packages/json-render/src/node/create-view.ts +++ b/packages/json-render/src/node/create-view.ts @@ -1,3 +1,4 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { DevframeNodeContext, DevframeScopedNodeContext } from 'devframe' import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import type { DevframeJsonRenderSpec, JsonRenderStatePatch, JsonRenderView } from '../types' @@ -8,7 +9,7 @@ import { JSON_RENDER_INDEX_KEY } from '../view-index' import { diagnostics } from './diagnostics' /** Options for {@link createJsonRenderView}. */ -export interface CreateJsonRenderViewOptions { +export interface CreateJsonRenderViewOptions { /** * Stable, author-supplied id, unique within the view's scope. Forms the * shared-state key `devframe:json-render::` and never changes @@ -16,7 +17,9 @@ export interface CreateJsonRenderViewOptions { */ id: string /** The initial spec. */ - spec: DevframeJsonRenderSpec + spec: SpecType + /** A replacement Standard Schema validator, or `false` to disable validation. */ + schema?: StandardSchemaV1 | false /** * Override the scope segment of the view's stable id. Defaults to the * context's namespace when created from a scoped context, otherwise @@ -98,9 +101,43 @@ function validateElementProps(id: string, spec: DevframeJsonRenderSpec): void { } } +function formatStandardSchemaIssues(issues: readonly StandardSchemaV1.Issue[]): string { + return issues + .map((issue) => { + const path = issue.path + ?.map(segment => typeof segment === 'object' ? String(segment.key) : String(segment)) + .join('.') + return `${path || '(root)'}: ${issue.message}` + }) + .join('; ') +} + +function isPromise(value: Result | Promise): value is Promise { + return typeof (value as Promise).then === 'function' +} + +function validateSpec( + id: string, + spec: DevframeJsonRenderSpec, + schema: StandardSchemaV1 | false | undefined, +): void { + if (schema === false) + return + if (!schema) { + validateElementProps(id, spec) + return + } + + const result = schema['~standard'].validate(spec) + if (isPromise(result)) + throw diagnostics.DF0074({ id }) + if (result.issues) + throw diagnostics.DF0073({ id, issues: formatStandardSchemaIssues(result.issues) }) +} + // Ensure the spec always carries a `state` object so JSON-Pointer patches // into `/state/...` have a container to target. -function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec { +function normalizeSpec(spec: SpecType): SpecType { return spec.state ? spec : { ...spec, state: {} } } @@ -119,10 +156,10 @@ function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec { * view.dispose() * ``` */ -export function createJsonRenderView( +export function createJsonRenderView( ctx: AnyContext, - options: CreateJsonRenderViewOptions, -): JsonRenderView { + options: CreateJsonRenderViewOptions, +): JsonRenderView { const scoped = isScoped(ctx) const baseCtx = scoped ? ctx.base : ctx const scope = options.scope ?? (scoped ? ctx.namespace : 'global') @@ -135,10 +172,10 @@ export function createJsonRenderView( throw diagnostics.DF0039({ id, scope }) const initial = normalizeSpec(options.spec) - validateElementProps(id, initial) + validateSpec(id, initial, options.schema) assertJsonSerializable(id, initial) - const state: SharedState = createSharedState({ + const state: SharedState = createSharedState({ initialValue: initial, enablePatches: true, }) @@ -165,11 +202,11 @@ export function createJsonRenderView( id, title, ref: { stateKey }, - value: () => state.value() as DevframeJsonRenderSpec, + value: () => state.value() as SpecType, update(spec) { assertLive() const next = normalizeSpec(spec) - validateElementProps(id, next) + validateSpec(id, next, options.schema) assertJsonSerializable(id, next) state.mutate(() => next) }, diff --git a/packages/json-render/src/node/diagnostics.ts b/packages/json-render/src/node/diagnostics.ts index f2b8927a..49c67bb0 100644 --- a/packages/json-render/src/node/diagnostics.ts +++ b/packages/json-render/src/node/diagnostics.ts @@ -1,9 +1,8 @@ import { defineDiagnostics } from 'devframe/utils/nostics' -// `@devframes/json-render` protocol/runtime diagnostics. These share the -// `DF` prefix and live in the devframe core range (next free after the -// current highest `DF00xx`, DF0037). Browser-only render failures keep -// `console.*` in the UI package. +// `@devframes/json-render` protocol/runtime diagnostics share the `DF` +// prefix and use the next globally available core codes. Browser-only render +// failures keep `console.*` in the UI package. export const diagnostics = defineDiagnostics({ docsBase: 'https://devfra.me/errors', codes: { @@ -27,5 +26,15 @@ export const diagnostics = defineDiagnostics({ `JSON-render view "${p.id}" spec is not JSON-serializable: ${p.reason}`, fix: 'Specs and state travel as strict JSON — remove functions, symbols, class instances, Map/Set, or circular references.', }, + DF0073: { + why: (p: { id: string, issues: string }) => + `JSON-render view "${p.id}" does not match its configured schema: ${p.issues}`, + fix: 'Match the authored spec to the Standard Schema passed to `createJsonRenderView`.', + }, + DF0074: { + why: (p: { id: string }) => + `JSON-render view "${p.id}" uses an asynchronous Standard Schema.`, + fix: 'Use a synchronous Standard Schema so initial creation and updates remain synchronous.', + }, }, }) diff --git a/packages/json-render/src/types.ts b/packages/json-render/src/types.ts index d0ed2122..e62bd3c9 100644 --- a/packages/json-render/src/types.ts +++ b/packages/json-render/src/types.ts @@ -1,4 +1,10 @@ -import type { Spec } from '@json-render/core' +import type { + Catalog, + InferCatalogComponents, + InferComponentProps, + Spec, + UIElement, +} from '@json-render/core' import type { JsonRenderViewStateRef } from './view-ref' /** @@ -6,7 +12,17 @@ import type { JsonRenderViewStateRef } from './view-ref' * `root` key, an `elements` map, and optional initial `state`. This alias is * the Devframes-facing name; it does not add or remove fields. */ -export type DevframeJsonRenderSpec = Spec +export type DevframeJsonRenderSpec = Omit & { + elements: Record +} + +/** Derive a discriminated element union from every component in a catalog. */ +export type CatalogUIElement = { + [ComponentName in keyof InferCatalogComponents & string]: UIElement< + ComponentName, + InferComponentProps + > +}[keyof InferCatalogComponents & string] /** * A single JSON-Pointer patch to a view's `state` model. `path` is an @@ -27,7 +43,7 @@ export interface JsonRenderStatePatch { * serializable {@link JsonRenderViewStateRef} that a hub dock (or any client * transport) uses to locate it. */ -export interface JsonRenderView { +export interface JsonRenderView { /** Author-supplied stable id, unique within the view's scope. */ readonly id: string /** Human-facing label published in the view index (defaults to `id`). */ @@ -35,7 +51,7 @@ export interface JsonRenderView { /** The serializable reference clients subscribe through. */ readonly ref: JsonRenderViewStateRef /** Replace the entire spec (a structural change replaces the whole spec). */ - update: (spec: DevframeJsonRenderSpec) => void + update: (spec: SpecType) => void /** * Apply JSON-Pointer patches to the view's `state`. Travels as a * shared-state patch (not a whole-spec snapshot), so only the changed @@ -43,7 +59,7 @@ export interface JsonRenderView { */ patchState: (patches: JsonRenderStatePatch[]) => void /** Read the current spec (immutable). */ - value: () => DevframeJsonRenderSpec + value: () => SpecType /** Unregister the shared state and its listeners. */ dispose: () => void } diff --git a/packages/json-render/src/view-ref.ts b/packages/json-render/src/view-ref.ts index 762b1c93..25af6698 100644 --- a/packages/json-render/src/view-ref.ts +++ b/packages/json-render/src/view-ref.ts @@ -18,9 +18,9 @@ export interface JsonRenderViewStateRef { * rendered as-is (static: local state and bindings still work, but there is no * server-driven live update stream). */ -export interface JsonRenderViewInlineRef { +export interface JsonRenderViewInlineRef { /** The full spec, carried in the reference itself. */ - spec: DevframeJsonRenderSpec + spec: SpecType } /** @@ -30,4 +30,5 @@ export interface JsonRenderViewInlineRef { * the client subscribes through, or an {@link JsonRenderViewInlineRef.spec * inline spec} rendered directly. */ -export type JsonRenderViewRef = JsonRenderViewStateRef | JsonRenderViewInlineRef +export type JsonRenderViewRef + = JsonRenderViewStateRef | JsonRenderViewInlineRef diff --git a/packages/json-render/test/catalog.test.ts b/packages/json-render/test/catalog.test.ts index 6b828551..72ee6eb0 100644 --- a/packages/json-render/test/catalog.test.ts +++ b/packages/json-render/test/catalog.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import type { CatalogUIElement, DevframeJsonRenderSpec, InferComponentProps } from '../src/index' +import { describe, expect, expectTypeOf, it } from 'vitest' import { baseCatalog, baseComponentNames, basePropSchemas } from '../src/index' describe('base catalog', () => { @@ -54,3 +55,18 @@ describe('per-component prop validation', () => { expect(basePropSchemas.Switch.safeParse({ value: { $state: '/enabled' } }).success).toBe(true) }) }) + +describe('catalog-derived element typing', () => { + it('narrows props when the element type is checked', () => { + type BaseCatalogElement = CatalogUIElement + const assertNarrowing = (element: BaseCatalogElement): void => { + if (element.type === 'Button') + expectTypeOf(element.props).toEqualTypeOf>() + if (element.type === 'Text') + expectTypeOf(element.props).toEqualTypeOf>() + } + + expectTypeOf['elements'][string]>().toEqualTypeOf() + expectTypeOf(assertNarrowing).toBeFunction() + }) +}) diff --git a/packages/json-render/test/create-view.test.ts b/packages/json-render/test/create-view.test.ts index 61e5e95b..34e1db67 100644 --- a/packages/json-render/test/create-view.test.ts +++ b/packages/json-render/test/create-view.test.ts @@ -1,3 +1,4 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { DevframeHost, DevframeNodeContext } from 'devframe' import type { DevframeJsonRenderSpec } from '../src/types' import { mkdtempSync } from 'node:fs' @@ -94,6 +95,100 @@ describe('createJsonRenderView validation', () => { circular.self = circular expect(() => createJsonRenderView(ctx, { id: 'circular', spec: circular })).toThrow(expect.objectContaining({ code: 'DF0041' })) }) + + it('uses a custom Standard Schema instead of base catalog validation', () => { + expect.assertions(1) + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: value => ({ value }), + }, + } + + const view = createJsonRenderView(ctx, { + id: 'custom', + spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'custom' } } } }, + schema, + }) + + expect(view.value().elements.a?.props).toEqual({ variant: 'custom' }) + }) + + it('rejects a spec that fails its custom Standard Schema', () => { + expect.assertions(1) + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => ({ issues: [{ message: 'Expected a supported root', path: ['root'] }] }), + }, + } + + expect(() => createJsonRenderView(ctx, { id: 'custom-invalid', spec, schema })).toThrow( + expect.objectContaining({ code: 'DF0073' }), + ) + }) + + it('keeps the previous spec when a custom schema rejects an update', () => { + expect.assertions(2) + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: (value) => { + const candidate = value as DevframeJsonRenderSpec + return candidate.root === 'a' + ? { value } + : { issues: [{ message: 'Only root a is supported', path: ['root'] }] } + }, + }, + } + const view = createJsonRenderView(ctx, { id: 'stable', spec, schema }) + + expect(() => view.update({ root: 'b', elements: {} })).toThrow(expect.objectContaining({ code: 'DF0073' })) + expect(view.value().root).toBe('a') + }) + + it('disables prop validation when schema is false', () => { + expect.assertions(1) + const view = createJsonRenderView(ctx, { + id: 'permissive', + spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'custom' } } } }, + schema: false, + }) + + expect(view.value().elements.a?.props).toEqual({ variant: 'custom' }) + }) + + it('uses Standard Schema as a guard without applying transformed output', () => { + expect.assertions(1) + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => ({ value: { root: 'transformed', elements: {} } }), + }, + } + const view = createJsonRenderView(ctx, { id: 'guard-only', spec, schema }) + + expect(view.value().root).toBe('a') + }) + + it('rejects asynchronous Standard Schemas with a clear diagnostic', () => { + expect.assertions(1) + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: async value => ({ value }), + }, + } + + expect(() => createJsonRenderView(ctx, { id: 'async-schema', spec, schema })).toThrow( + expect.objectContaining({ code: 'DF0074' }), + ) + }) }) describe('createJsonRenderView index', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be89ef3e..c30a1dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1522,6 +1522,9 @@ importers: '@json-render/core': specifier: catalog:deps version: 0.19.0(zod@4.4.3) + '@standard-schema/spec': + specifier: catalog:deps + version: 1.1.0 zod: specifier: catalog:deps version: 4.4.3 From 5bdbcf69c7c1fa422e63273542bc451163de9be5 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 20 Aug 2026 18:07:32 +0200 Subject: [PATCH 2/2] test(json-render): update public API snapshots --- .../json-render/index.snapshot.d.ts | 17 +++++++++------- .../@devframes/json-render/node.snapshot.d.ts | 20 ++++++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts index a77e49f2..9a23ad5c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts @@ -8,17 +8,17 @@ export interface JsonRenderIndexEntry { stateKey: string; title: string; } -export interface JsonRenderView { +export interface JsonRenderView { readonly id: string; readonly title: string; readonly ref: JsonRenderViewStateRef; - update: (_: DevframeJsonRenderSpec) => void; + update: (_: SpecType) => void; patchState: (_: JsonRenderStatePatch[]) => void; - value: () => DevframeJsonRenderSpec; + value: () => SpecType; dispose: () => void; } -export interface JsonRenderViewInlineRef { - spec: DevframeJsonRenderSpec; +export interface JsonRenderViewInlineRef { + spec: SpecType; } export interface JsonRenderViewStateRef { stateKey: string; @@ -27,9 +27,12 @@ export interface JsonRenderViewStateRef { // #region Types export type BaseComponentName = keyof typeof basePropSchemas; -export type DevframeJsonRenderSpec = Spec; +export type CatalogUIElement = { [ComponentName in keyof InferCatalogComponents & string]: UIElement>; }[keyof InferCatalogComponents & string]; +export type DevframeJsonRenderSpec = Omit & { + elements: Record; +}; export type JsonRenderIndex = Record; -export type JsonRenderViewRef = JsonRenderViewStateRef | JsonRenderViewInlineRef; +export type JsonRenderViewRef = JsonRenderViewStateRef | JsonRenderViewInlineRef; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts index cda01523..2d094289 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts @@ -2,16 +2,17 @@ * Generated by tsnapi — public API snapshot of `@devframes/json-render/node` */ // #region Interfaces -export interface CreateJsonRenderViewOptions { +export interface CreateJsonRenderViewOptions { id: string; - spec: DevframeJsonRenderSpec; + spec: SpecType; + schema?: StandardSchemaV1 | false; scope?: string; title?: string; } // #endregion // #region Functions -export declare function createJsonRenderView(_: AnyContext, _: CreateJsonRenderViewOptions): JsonRenderView; +export declare function createJsonRenderView(_: AnyContext, _: CreateJsonRenderViewOptions): JsonRenderView; // #endregion // #region Variables @@ -44,6 +45,19 @@ export declare const jsonRenderDiagnostics: Diagnostics<{ }) => string; readonly fix: "Specs and state travel as strict JSON — remove functions, symbols, class instances, Map/Set, or circular references."; }; + readonly DF0073: { + readonly why: (p: { + id: string; + issues: string; + }) => string; + readonly fix: "Match the authored spec to the Standard Schema passed to `createJsonRenderView`."; + }; + readonly DF0074: { + readonly why: (p: { + id: string; + }) => string; + readonly fix: "Use a synchronous Standard Schema so initial creation and updates remain synchronous."; + }; }, readonly [(d: Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void]>;