diff --git a/alias.ts b/alias.ts index 6940f8e2f..8f54233d6 100644 --- a/alias.ts +++ b/alias.ts @@ -77,6 +77,7 @@ export const alias = { '@devframes/vite': r('vite/src/index.ts'), '@devframes/json-render/core': r('json-render/src/core.ts'), '@devframes/json-render/hub': r('json-render/src/hub.ts'), + '@devframes/json-render/view': r('json-render/src/node/create-view.ts'), '@devframes/json-render/node': r('json-render/src/node/index.ts'), '@devframes/json-render': r('json-render/src/index.ts'), '@devframes/json-render-ui/hub': r('json-render-ui/src/hub.ts'), diff --git a/docs/content/1.guide/21.build-your-own-json-render-frontend.md b/docs/content/1.guide/21.build-your-own-json-render-frontend.md index cdeb92059..fd19636f1 100644 --- a/docs/content/1.guide/21.build-your-own-json-render-frontend.md +++ b/docs/content/1.guide/21.build-your-own-json-render-frontend.md @@ -29,7 +29,7 @@ Resolve the entry's `view`: it as the live spec, re-render on `'updated'`. **Unsubscribe in `dispose`.** - `{ spec }`: render the embedded spec directly. -Detect static output via `context.rpc.connectionMeta.backend === 'static'`, +Detect static output via `context.rpc.connectionMeta?.backend === 'static'`, disabling action dispatch there. ## Behavior expectations diff --git a/docs/content/1.guide/8.json-render.md b/docs/content/1.guide/8.json-render.md index 7eace6675..199bcae67 100644 --- a/docs/content/1.guide/8.json-render.md +++ b/docs/content/1.guide/8.json-render.md @@ -167,3 +167,34 @@ for a browser-synthesized [client-only dock](/guide/client-context#client-only-d reference implementation. See [Build your own JSON-Render frontend](/guide/build-your-own-json-render-frontend) and the [`json-render` example](https://github.com/devframes/devframe/tree/main/examples/json-render). + +## Rendering with a custom RPC channel + +A host page can import the reference browser bundle directly. It includes its renderer and styles. TypeScript consumers also install the `@devframes/json-render`, `@devframes/hub` and `devframe` peers for the renderer declarations: + +```ts +import renderer from '@devframes/json-render-ui/renderer' + +const mounted = await renderer({ + entry, + container, + context: { rpc: { call, sharedState } }, +}) + +// When the surface closes: +mounted.dispose?.() +``` + +`call` and `sharedState` are native RPC members. An optional `connectionMeta.backend` marks static output. The reference implementation accepts this smaller `JsonRenderRpcContext`; a full hub client context also satisfies it. Custom `JsonRenderDockRenderer` implementations retain the full client context by default and can declare their own context type through its generic parameter. + +For view publication in a worker, import `createJsonRenderView` from `@devframes/json-render/view`. Its native publishing state must support `get(key, { sharedState })`. Reuse one context object for all views on that state instance. View discovery and duplicate detection belong to this context: + +```ts +import { createJsonRenderView } from '@devframes/json-render/view' + +const context = { rpc: { sharedState } } +const metrics = createJsonRenderView(context, { id: 'metrics', spec: metricsSpec }) +const details = createJsonRenderView(context, { id: 'details', spec: detailsSpec }) +``` + +The node-side import and scoped node contexts use the same implementation. diff --git a/packages/json-render-ui/package.json b/packages/json-render-ui/package.json index 2885d73ce..98c139fbd 100644 --- a/packages/json-render-ui/package.json +++ b/packages/json-render-ui/package.json @@ -22,6 +22,10 @@ "exports": { "./hub": "./dist/hub.mjs", "./spa": "./dist/spa.mjs", + "./renderer": { + "types": "./dist/renderer.d.mts", + "default": "./dist/renderer/json-render.mjs" + }, "./package.json": "./package.json" }, "files": [ @@ -39,12 +43,16 @@ }, "peerDependencies": { "@devframes/hub": "workspace:*", + "@devframes/json-render": "workspace:*", "devframe": "workspace:*" }, "peerDependenciesMeta": { "@devframes/hub": { "optional": true }, + "@devframes/json-render": { + "optional": true + }, "devframe": { "optional": true } diff --git a/packages/json-render-ui/src/dock-renderer.ts b/packages/json-render-ui/src/dock-renderer.ts index 4bf497563..7dd6ab30f 100644 --- a/packages/json-render-ui/src/dock-renderer.ts +++ b/packages/json-render-ui/src/dock-renderer.ts @@ -1,5 +1,5 @@ import type { JsonRenderViewRef, Spec } from '@devframes/json-render' -import type { JsonRenderDockRenderer } from '@devframes/json-render/hub' +import type { JsonRenderDockRenderer, JsonRenderRpcContext } from '@devframes/json-render/hub' import type { ComponentRegistry } from '@json-render/vue' import type { ActionBridgeRpc } from './action-bridge' import { createApp, h, shallowRef } from 'vue' @@ -33,7 +33,7 @@ export interface JsonRenderDockRendererOptions { */ export function createJsonRenderDockRenderer( options: JsonRenderDockRendererOptions = {}, -): JsonRenderDockRenderer { +): JsonRenderDockRenderer { const registry = options.registry ?? baseRegistry return async ({ entry, container, context }) => { const view: JsonRenderViewRef = entry.view diff --git a/packages/json-render-ui/src/renderer-module/index.ts b/packages/json-render-ui/src/renderer-module/index.ts index 72e7eb0c2..7360c0b51 100644 --- a/packages/json-render-ui/src/renderer-module/index.ts +++ b/packages/json-render-ui/src/renderer-module/index.ts @@ -1,4 +1,5 @@ -import type { JsonRenderDockRenderer } from '@devframes/json-render/hub' +import type { DockRendererInstance } from '@devframes/hub/client' +import type { JsonRenderDockRenderer, JsonRenderRpcContext } from '@devframes/json-render/hub' import css from '../.generated/css' import { createJsonRenderDockRenderer } from '../dock-renderer' @@ -32,7 +33,7 @@ const inner = createJsonRenderDockRenderer() * there, fully styled in a light-DOM host page and inside a viewer's shadow * root alike, without leaking the reset or any global rule into the page. */ -const jsonRenderDockRenderer: JsonRenderDockRenderer = async ({ entry, container, context }) => { +const jsonRenderDockRenderer: JsonRenderDockRenderer = async ({ entry, container, context }) => { const shadow = container.shadowRoot ?? container.attachShadow({ mode: 'open' }) if (!shadow.querySelector(`style[${STYLE_MARKER}]`)) { const style = document.createElement('style') @@ -66,7 +67,15 @@ const jsonRenderDockRenderer: JsonRenderDockRenderer = async ({ entry, container colorSchemeRoot.append(root) shadow.append(colorSchemeRoot) - const instance = await inner({ entry, container: root, context }) + let instance: DockRendererInstance + try { + instance = await inner({ entry, container: root, context }) + } + catch (error) { + observer.disconnect() + colorSchemeRoot.remove() + throw error + } return { dispose() { observer.disconnect() diff --git a/packages/json-render-ui/tsdown.config.ts b/packages/json-render-ui/tsdown.config.ts index a9ec100fe..65070f6c7 100644 --- a/packages/json-render-ui/tsdown.config.ts +++ b/packages/json-render-ui/tsdown.config.ts @@ -14,7 +14,7 @@ import { defineConfig } from 'tsdown' * emitted `.d.mts` references the packages instead of inlining their whole * type graph. */ -export default defineConfig({ +export default defineConfig([{ entry: { /** * Node-safe entry: the prebuilt SPA path + a devframe wiring helper. @@ -46,4 +46,11 @@ export default defineConfig({ '@devframes/json-render/core', ], }, -}) +}, { + entry: { renderer: 'src/renderer-module/index.ts' }, + clean: false, + tsconfig: '../../tsconfig.base.json', + dts: { emitDtsOnly: true }, + outExtensions: () => ({ dts: '.d.mts' }), + deps: { neverBundle: ['@devframes/json-render/hub'] }, +}]) diff --git a/packages/json-render/package.json b/packages/json-render/package.json index cccaac97b..309f629df 100644 --- a/packages/json-render/package.json +++ b/packages/json-render/package.json @@ -23,6 +23,7 @@ "./core": "./dist/core.mjs", "./hub": "./dist/hub.mjs", "./node": "./dist/node/index.mjs", + "./view": "./dist/view.mjs", "./package.json": "./package.json" }, "types": "./dist/index.d.mts", diff --git a/packages/json-render/src/hub.ts b/packages/json-render/src/hub.ts index 991860dcd..7a3056121 100644 --- a/packages/json-render/src/hub.ts +++ b/packages/json-render/src/hub.ts @@ -1,5 +1,7 @@ -import type { DockRenderer, DockRendererMountOptions } from '@devframes/hub/client' +import type { DevframeClientContext, DockRendererInstance, DockRendererMountOptions } from '@devframes/hub/client' import type { DevframeDockEntryBase } from '@devframes/hub/types' +import type { DevframeRpcClient } from 'devframe/client' +import type { ConnectionMeta } from 'devframe/types' import type { JsonRenderView } from './types' import type { JsonRenderViewRef } from './view-ref' @@ -26,12 +28,22 @@ declare module '@devframes/hub/types' { /** * The mount options a hub viewer hands a json-render dock renderer: the - * hub's `DockRendererMountOptions` narrowed to the `'json-render'` entry. + * hub's `DockRendererMountOptions` narrowed to the `'json-render'` entry + * and an optional context type. Existing renderers keep the full client context. * This protocol package owns the renderer contract so every frontend * (`@devframes/json-render-ui`, a community renderer, a host page's own) * implements one shared shape instead of re-declaring it. */ -export type JsonRenderDockMountOptions = DockRendererMountOptions +export type JsonRenderDockMountOptions = Omit, 'context'> & { + context: Context +} + +/** Native RPC calls and shared state consumed by the reference JSON renderer. */ +export interface JsonRenderRpcContext { + rpc: Pick & { + connectionMeta?: Pick + } +} /** * The renderer contract for `'json-render'` docks: a hub `DockRenderer` @@ -41,7 +53,9 @@ export type JsonRenderDockMountOptions = DockRendererMountOptions +export type JsonRenderDockRenderer = ( + options: JsonRenderDockMountOptions, +) => DockRendererInstance | Promise /** * Build a `json-render` dock entry from a {@link JsonRenderView} and the dock diff --git a/packages/json-render/src/node/create-view.ts b/packages/json-render/src/node/create-view.ts index 2051fe109..6f2542c18 100644 --- a/packages/json-render/src/node/create-view.ts +++ b/packages/json-render/src/node/create-view.ts @@ -1,4 +1,4 @@ -import type { DevframeNodeContext, DevframeScopedNodeContext } from 'devframe' +import type { RpcSharedStateHost } from 'devframe/types' import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' import type { StandardSchemaV1 } from 'devframe/utils/simple-schema' import type { DevframeJsonRenderSpec, JsonRenderStatePatch, JsonRenderView } from '../types' @@ -34,9 +34,20 @@ export interface CreateJsonRenderViewOptions +/** Reuse one context per native shared-state instance for view discovery and duplicate detection. */ +export interface JsonRenderViewContext { + rpc: { sharedState: RpcSharedStateHost } +} + +/** Namespace and base shared state supplied by a scoped node context. */ +export interface JsonRenderScopedViewContext { + base: JsonRenderViewContext + namespace: string +} + +type AnyContext = JsonRenderViewContext | JsonRenderScopedViewContext -function isScoped(ctx: AnyContext): ctx is DevframeScopedNodeContext { +function isScoped(ctx: AnyContext): ctx is JsonRenderScopedViewContext { return 'base' in ctx && 'namespace' in ctx } @@ -44,7 +55,7 @@ function isScoped(ctx: AnyContext): ctx is DevframeScopedNodeContext { // scope is caught deterministically (not left to shared-state get() returning // the pre-existing entry). const registries = new WeakMap>() -function registryFor(ctx: DevframeNodeContext): Set { +function registryFor(ctx: JsonRenderViewContext): Set { let set = registries.get(ctx) if (!set) { set = new Set() @@ -57,7 +68,7 @@ function registryFor(ctx: DevframeNodeContext): Set { // `JSON_RENDER_INDEX_KEY`, so a frontend that does not know view ids ahead of // time can discover every live view from a single subscription. const indexStates = new WeakMap>() -function indexStateFor(ctx: DevframeNodeContext): SharedState { +function indexStateFor(ctx: JsonRenderViewContext): SharedState { let state = indexStates.get(ctx) if (!state) { state = createSharedState({ initialValue: {} }) diff --git a/packages/json-render/test/create-view.test.ts b/packages/json-render/test/create-view.test.ts index f98cf98a0..808674b20 100644 --- a/packages/json-render/test/create-view.test.ts +++ b/packages/json-render/test/create-view.test.ts @@ -4,6 +4,7 @@ import type { DevframeJsonRenderSpec } from '../src/types' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createJsonRenderView as createPortableView } from '@devframes/json-render/view' import { createHostContext } from 'devframe/node' import { beforeEach, describe, expect, it } from 'vitest' import { createJsonRenderView } from '../src/node/index' @@ -57,6 +58,23 @@ describe('createJsonRenderView identity', () => { }) describe('createJsonRenderView state', () => { + it('publishes and disposes through a shared-state-only context', async () => { + expect.assertions(5) + const context = { rpc: { sharedState: ctx.rpc.sharedState } } + const view = createPortableView(context, { id: 'portable', spec }) + const state = await context.rpc.sharedState.get(view.ref.stateKey) + expect(state.value()).toEqual(spec) + const secondView = createPortableView(context, { id: 'second', spec }) + const index = await context.rpc.sharedState.get(JSON_RENDER_INDEX_KEY) + expect(Object.keys(index.value())).toEqual([view.ref.stateKey, secondView.ref.stateKey]) + expect(() => createPortableView(context, { id: 'portable', spec })).toThrow() + view.patchState([{ op: 'replace', path: '/count', value: 2 }]) + expect(state.value().state).toEqual({ count: 2 }) + view.dispose() + expect(context.rpc.sharedState.keys()).not.toContain(view.ref.stateKey) + secondView.dispose() + }) + it('registers a shared state carrying the spec', async () => { const view = createJsonRenderView(ctx, { id: 'v', spec }) expect(ctx.rpc.sharedState.keys()).toContain(view.ref.stateKey) diff --git a/packages/json-render/tsdown.config.ts b/packages/json-render/tsdown.config.ts index 235772d3e..16c1fe5d5 100644 --- a/packages/json-render/tsdown.config.ts +++ b/packages/json-render/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ 'index': 'src/index.ts', 'core': 'src/core.ts', 'hub': 'src/hub.ts', + 'view': 'src/node/create-view.ts', 'node/index': 'src/node/index.ts', }, outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.d.ts new file mode 100644 index 000000000..1e902a429 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.d.ts @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui/renderer` + */ +// #region Default Export +declare const _default: JsonRenderDockRenderer; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.js new file mode 100644 index 000000000..0e7bc45fd --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/renderer.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui/renderer` + */ +// #region Default Export +var _default +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts index 3b2a2066c..a2cf3a573 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts @@ -6,11 +6,18 @@ export interface DevframeJsonRenderDockEntry extends DevframeDockEntryBase { type: 'json-render'; view: JsonRenderViewRef; } +export interface JsonRenderRpcContext { + rpc: Pick & { + connectionMeta?: Pick; + }; +} // #endregion // #region Types -export type JsonRenderDockMountOptions = DockRendererMountOptions; -export type JsonRenderDockRenderer = DockRenderer; +export type JsonRenderDockMountOptions = Omit, 'context'> & { + context: Context; +}; +export type JsonRenderDockRenderer = (_: JsonRenderDockMountOptions) => DockRendererInstance | Promise; // #endregion // #region Functions 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 c371991cc..0f88abf88 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts @@ -1,24 +1,11 @@ /** * Generated by tsnapi — public API snapshot of `@devframes/json-render/node` */ -// #region Interfaces -export interface CreateJsonRenderViewOptions { - id: string; - spec: SpecType; - schema?: StandardSchemaV1 | false; - scope?: string; - title?: string; -} -// #endregion - -// #region Functions -export declare function createJsonRenderView(_: AnyContext, _: CreateJsonRenderViewOptions): JsonRenderView; -// #endregion - // #region Variables export declare const jsonRenderDiagnostics: DevframeDiagnostics; // #endregion -// #region Referenced (internal) -type AnyContext = DevframeNodeContext | DevframeScopedNodeContext; +// #region Other +export { createJsonRenderView } +export { CreateJsonRenderViewOptions } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js index a5fc4c5da..038c001c4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js @@ -1,10 +1,7 @@ /** * Generated by tsnapi — public API snapshot of `@devframes/json-render/node` */ -// #region Functions -export function createJsonRenderView(_, _) {} -// #endregion - -// #region Variables -export var jsonRenderDiagnostics /* const */ +// #region Other +export { createJsonRenderView } +export { diagnostics as jsonRenderDiagnostics } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.d.ts new file mode 100644 index 000000000..ed4ead24e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.d.ts @@ -0,0 +1,29 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/view` + */ +// #region Interfaces +export interface CreateJsonRenderViewOptions { + id: string; + spec: SpecType; + schema?: StandardSchemaV1 | false; + scope?: string; + title?: string; +} +export interface JsonRenderScopedViewContext { + base: JsonRenderViewContext; + namespace: string; +} +export interface JsonRenderViewContext { + rpc: { + sharedState: RpcSharedStateHost; + }; +} +// #endregion + +// #region Functions +export declare function createJsonRenderView(_: AnyContext, _: CreateJsonRenderViewOptions): JsonRenderView; +// #endregion + +// #region Referenced (internal) +type AnyContext = JsonRenderViewContext | JsonRenderScopedViewContext; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.js new file mode 100644 index 000000000..9b99fa2c6 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/view.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/view` + */ +// #region Functions +export function createJsonRenderView(_, _) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 4839e92af..23a4a4081 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -214,6 +214,9 @@ "@devframes/json-render/hub": [ "./packages/json-render/src/hub.ts" ], + "@devframes/json-render/view": [ + "./packages/json-render/src/node/create-view.ts" + ], "@devframes/json-render/node": [ "./packages/json-render/src/node/index.ts" ],