Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/errors/DF0073.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/errors/DF0074.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
1 change: 1 addition & 0 deletions packages/json-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
},
"dependencies": {
"@json-render/core": "catalog:deps",
"@standard-schema/spec": "catalog:deps",
"zod": "catalog:deps"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/json-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
57 changes: 47 additions & 10 deletions packages/json-render/src/node/create-view.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,15 +9,17 @@ import { JSON_RENDER_INDEX_KEY } from '../view-index'
import { diagnostics } from './diagnostics'

/** Options for {@link createJsonRenderView}. */
export interface CreateJsonRenderViewOptions {
export interface CreateJsonRenderViewOptions<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
/**
* Stable, author-supplied id, unique within the view's scope. Forms the
* shared-state key `devframe:json-render:<scope>:<id>` and never changes
* across updates, so a client keeps its subscription across reconnects.
*/
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
Expand Down Expand Up @@ -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<Result>(value: Result | Promise<Result>): value is Promise<Result> {
return typeof (value as Promise<Result>).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<SpecType extends DevframeJsonRenderSpec>(spec: SpecType): SpecType {
return spec.state ? spec : { ...spec, state: {} }
}

Expand All @@ -119,10 +156,10 @@ function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec {
* view.dispose()
* ```
*/
export function createJsonRenderView(
export function createJsonRenderView<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec>(
ctx: AnyContext,
options: CreateJsonRenderViewOptions,
): JsonRenderView {
options: CreateJsonRenderViewOptions<SpecType>,
): JsonRenderView<SpecType> {
const scoped = isScoped(ctx)
const baseCtx = scoped ? ctx.base : ctx
const scope = options.scope ?? (scoped ? ctx.namespace : 'global')
Expand All @@ -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<DevframeJsonRenderSpec> = createSharedState({
const state: SharedState<SpecType> = createSharedState({
initialValue: initial,
enablePatches: true,
})
Expand All @@ -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)
},
Expand Down
17 changes: 13 additions & 4 deletions packages/json-render/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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.',
},
},
})
26 changes: 21 additions & 5 deletions packages/json-render/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import type { Spec } from '@json-render/core'
import type {
Catalog,
InferCatalogComponents,
InferComponentProps,
Spec,
UIElement,
} from '@json-render/core'
import type { JsonRenderViewStateRef } from './view-ref'

/**
* A Devframes JSON-render spec **is** an `@json-render/core` `Spec`: a flat
* `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<Element extends UIElement = UIElement> = Omit<Spec, 'elements'> & {
elements: Record<string, Element>
}

/** Derive a discriminated element union from every component in a catalog. */
export type CatalogUIElement<CatalogType extends Catalog> = {
[ComponentName in keyof InferCatalogComponents<CatalogType> & string]: UIElement<
ComponentName,
InferComponentProps<CatalogType, ComponentName>
>
}[keyof InferCatalogComponents<CatalogType> & string]

/**
* A single JSON-Pointer patch to a view's `state` model. `path` is an
Expand All @@ -27,23 +43,23 @@ export interface JsonRenderStatePatch {
* serializable {@link JsonRenderViewStateRef} that a hub dock (or any client
* transport) uses to locate it.
*/
export interface JsonRenderView {
export interface JsonRenderView<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
/** Author-supplied stable id, unique within the view's scope. */
readonly id: string
/** Human-facing label published in the view index (defaults to `id`). */
readonly title: string
/** 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
* paths cross the wire.
*/
patchState: (patches: JsonRenderStatePatch[]) => void
/** Read the current spec (immutable). */
value: () => DevframeJsonRenderSpec
value: () => SpecType
/** Unregister the shared state and its listeners. */
dispose: () => void
}
7 changes: 4 additions & 3 deletions packages/json-render/src/view-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec> {
/** The full spec, carried in the reference itself. */
spec: DevframeJsonRenderSpec
spec: SpecType
}

/**
Expand All @@ -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<SpecType extends DevframeJsonRenderSpec = DevframeJsonRenderSpec>
= JsonRenderViewStateRef | JsonRenderViewInlineRef<SpecType>
18 changes: 17 additions & 1 deletion packages/json-render/test/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<typeof baseCatalog>
const assertNarrowing = (element: BaseCatalogElement): void => {
if (element.type === 'Button')
expectTypeOf(element.props).toEqualTypeOf<InferComponentProps<typeof baseCatalog, 'Button'>>()
if (element.type === 'Text')
expectTypeOf(element.props).toEqualTypeOf<InferComponentProps<typeof baseCatalog, 'Text'>>()
}

expectTypeOf<DevframeJsonRenderSpec<BaseCatalogElement>['elements'][string]>().toEqualTypeOf<BaseCatalogElement>()
expectTypeOf(assertNarrowing).toBeFunction()
})
})
Loading
Loading