Skip to content

Commit ef831b9

Browse files
committed
feat!: swappable UI packages — renderer-module manifest + missing-renderer fallback
The headless/UI split becomes fully swappable, both ways, independently (plan 033): - @devframes/hub: initHub({ renderers }) serves prebuilt renderer modules at <base>__renderers/<type>.mjs and publishes a renderer manifest over shared state; the client registry lazy-imports manifest modules (local registrations win) and mount() resolves a typed result (mounted / missing-renderer / load-error). DF8108–DF8110 diagnostics. - @devframes/json-render: owns the renderer contract (JsonRenderDockRenderer / JsonRenderDockMountOptions on ./hub). - @devframes/json-render-ui: ships a self-contained, self-styling, shadow-root-safe renderer module plus the jsonRenderUiRenderer() registration helper on the new ./hub entry. - @devframes/hub-ui: drops its bundled json-render components; every non-native dock type routes through the registry, with a generic missing-renderer fallback view (load-error variant with retry). - Examples: minimal hosts compose json-render-ui via the manifest one-liner; hub-vite consumes the manifest, hub-next overrides it with a local React renderer, and both witness the fallback with an unrendered dock type. - Docs: renderer-modules guide section, build-your-own-hub-ui and build-your-own-json-render-frontend guides, error pages, 0.9 migration notes. BREAKING CHANGE: renderers.mount() resolves a DockRendererMountResult instead of a bare disposer, and @devframes/hub-ui no longer bundles a json-render renderer — compose one via initHub({ renderers: [jsonRenderUiRenderer()] }).
1 parent 5dc9b97 commit ef831b9

92 files changed

Lines changed: 1918 additions & 2062 deletions

File tree

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const alias = {
5757
'@devframes/json-render/node': r('json-render/src/node/index.ts'),
5858
'@devframes/json-render': r('json-render/src/index.ts'),
5959
'@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'),
60+
'@devframes/json-render-ui/hub': r('json-render-ui/src/hub.ts'),
6061
'@devframes/json-render-ui/spa': r('json-render-ui/src/spa.ts'),
6162
'@devframes/json-render-ui': r('json-render-ui/src/index.ts'),
6263
'json-render/dashboard': fileURLToPath(new URL('./examples/json-render/src/dashboard.ts', import.meta.url)),

docs/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ function guideItems(prefix: string) {
3535
{ text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` },
3636
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
3737
{ text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` },
38+
{ text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` },
39+
{ text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` },
3840
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
3941
] satisfies DefaultTheme.NavItemWithLink[]
4042
}

docs/errors/DF8108.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+
# DF8108: Duplicate Renderer Module Type
6+
7+
## Message
8+
9+
> A renderer module is already registered for dock type "`{type}`"
10+
11+
## Cause
12+
13+
`initHub({ renderers })` received two registrations carrying the same `type`. Each dock type resolves to exactly one renderer module in the hub's renderer manifest — the module served at `<base>__renderers/<type>.mjs` — so a second registration for the same type would be unreachable.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
renderers: [
20+
jsonRenderUiRenderer(),
21+
{ type: 'json-render', file: myOtherRenderer }, // ✗ duplicate type
22+
],
23+
})
24+
```
25+
26+
## Fix
27+
28+
- Keep one registration per dock type — pick the implementation you want the manifest to serve.
29+
- To override a manifest module for one specific client, register a renderer locally instead (`createDevframeClientHost({ renderers })`); local registrations take precedence.
30+
31+
## Source
32+
33+
- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts)`resolveRendererRegistrations()` throws when a `type` repeats.

docs/errors/DF8109.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8109: Renderer Module File Missing
6+
7+
## Message
8+
9+
> The renderer module registered for dock type "`{type}`" does not exist at "`{file}`"
10+
11+
## Cause
12+
13+
An `initHub({ renderers })` registration points at a file that isn't on disk. Renderer modules are prebuilt, self-contained browser ES modules the hub serves verbatim at `<base>__renderers/<type>.mjs` — a missing bundle would make every client's lazy import 404 at mount time, so the hub fails fast at startup instead.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
renderers: [
20+
{ type: 'json-render', file: '/path/that/was/never/built.mjs' }, //
21+
],
22+
})
23+
```
24+
25+
## Fix
26+
27+
- Build the renderer package first — the bundle is a build artifact (e.g. `@devframes/json-render-ui`'s `dist/renderer/json-render.mjs`).
28+
- Prefer the package's registration helper over a hand-written path — `jsonRenderUiRenderer()` from `@devframes/json-render-ui/hub` resolves the shipped bundle for you.
29+
30+
## Source
31+
32+
- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts)`resolveRendererRegistrations()` throws when the resolved `file` fails the existence probe.

docs/errors/DF8110.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8110: Renderer Type Is Not URL-Safe
6+
7+
## Message
8+
9+
> Dock type "`{type}`" is not a servable renderer-module name — the hub serves each module at `<base>__renderers/<type>.mjs`
10+
11+
## Cause
12+
13+
An `initHub({ renderers })` registration carries a `type` that can't become a URL segment. The hub derives each module's serving path — and the manifest's `importFrom` — from the type, so `:` and `*` (route-pattern markers to the underlying router) or separators like `/` would break the route.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
renderers: [
20+
{ type: 'my:renderer', file: bundle }, // ✗ `:` is a route-param marker
21+
],
22+
})
23+
```
24+
25+
## Fix
26+
27+
Use a route-safe dock type: letters, digits, `_`, `-`, and `.` only (e.g. `json-render`, `my-renderer`). The dock entries' `type` discriminator must match, so pick the safe name once, in the integration that declares the type.
28+
29+
## Source
30+
31+
- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts)`resolveRendererRegistrations()` rejects a `type` failing the `[\w.-]+` segment check.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Build Your Own Hub UI
2+
3+
A hub viewer is a replaceable implementation of two contracts — the node-side
4+
`ui` slot and the client-side context — so you can ship a completely custom
5+
devtools surface (your framework, your design system) on top of the hub's
6+
infrastructure. `@devframes/hub-ui` is the reference implementation of both;
7+
this page is the map for writing another.
8+
9+
## The node seam: `DevframeHubUi`
10+
11+
`initHub({ ui })` takes pure data (see [the `ui`
12+
slot](./hub-initiate#the-ui-slot)):
13+
14+
```ts
15+
interface DevframeHubUi {
16+
viewer?: { distDir: string } // a standalone SPA served at the hub base
17+
embedded?: { entry: string } // a self-contained bootstrap at <base>embedded.js
18+
assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
19+
}
20+
```
21+
22+
Ship a function returning this object (the reference is `createUi()`), with
23+
prebuilt assets: the viewer SPA is built with relative asset paths, and the
24+
embedded entry is one self-contained ES module that mounts your dock into any
25+
host page.
26+
27+
## The client contracts
28+
29+
A viewer renders from the hub's shared state and drives it through
30+
`@devframes/hub/client`. The simplest boot is
31+
[`createDevframeClientHost()`](./client-context) — it assembles the whole
32+
`DevframeClientContext` (docks, commands, renderers, when-clauses, connection)
33+
and loads dock client scripts for you; the reference UI assembles the same
34+
context shape with its own reactive machinery instead. Either way, honor these
35+
contracts:
36+
37+
### Dock entry types
38+
39+
Render the built-in variants of the open dock union
40+
(`DevframeDockEntryRegistry` from `@devframes/hub/types`):
41+
42+
| Type | The viewer renders |
43+
|---|---|
44+
| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` for shared frames); honor `subTabs` soft navigation |
45+
| `action` | a bar button only — activating it runs the entry's client script |
46+
| `custom-render` | a container the entry's client script mounts into |
47+
| `launcher` | a launch call-to-action reflecting `launcher.status` |
48+
| `group` | one bar button collapsing its member entries |
49+
| `~builtin` | your own native views (settings, feeds) for reserved ids |
50+
51+
Honor `when` / `visibility` clauses, `category` grouping (order from
52+
`DEFAULT_CATEGORIES_ORDER` in `@devframes/hub/constants`), and the
53+
`hub:docks:activate` broadcast.
54+
55+
### The renderer registry and its fallback
56+
57+
**Every other dock type routes through the dock-renderer registry** — build it
58+
with `createDockRenderersContext()` from `@devframes/hub/client` so local
59+
registrations, the hub's [renderer
60+
manifest](./hub-initiate#renderer-modules), and the typed mount result behave
61+
like every other viewer:
62+
63+
```ts
64+
import { createDockRenderersContext } from '@devframes/hub/client'
65+
66+
const renderers = createDockRenderersContext({
67+
context: () => context,
68+
manifest: () => manifestState.value(), // the devframe:dock-renderers slot
69+
})
70+
71+
const result = await renderers.mount(entry, container)
72+
```
73+
74+
The mount result is the fallback contract. A viewer shows a visible state for
75+
each variant instead of a dead panel:
76+
77+
- `{ status: 'mounted', dispose }` — the renderer owns the container; call
78+
`dispose` when the view unmounts.
79+
- `{ status: 'missing-renderer' }` — render a fallback view: *No renderer for
80+
"`<type>`" in the current environment*. `renderers.has(type)` answers up
81+
front, so you can render this declaratively without a mount attempt.
82+
- `{ status: 'load-error', error }` — the module failed to import or the
83+
renderer threw; render the error with a retry affordance (a failed import is
84+
not cached, so retrying re-imports).
85+
86+
### The theme contract for renderers
87+
88+
Renderer modules style themselves (they may attach a shadow root inside your
89+
container). Your part: keep a live `dark` class on the mount container
90+
reflecting your color mode, and let CSS custom properties inherit — a
91+
`--devframe-primary` set on an ancestor rebrands rendered content too.
92+
93+
## Reference points
94+
95+
- `packages/hub-ui` — the full reference viewer (Vue, `@antfu/design`).
96+
- [`examples/hub-vite`](/examples/hub-vite) and
97+
[`examples/hub-next`](/examples/hub-next) — protocol witnesses: complete
98+
hand-rolled viewers in ~500 lines of vanilla DOM and React respectively,
99+
covering docks, the drawer subsystems, the renderer registry, and the
100+
missing-renderer fallback.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Build Your Own JSON-Render Frontend
2+
3+
`@devframes/json-render-ui` is the reference frontend, not the protocol — any
4+
implementation of the renderer contract replaces it, in any framework. The
5+
[Next hub witness](/examples/hub-next) ships a complete React one in two files
6+
(`src/client/json-render/`); this page is the contract it implements.
7+
8+
## The contract
9+
10+
`@devframes/json-render/hub` owns the types:
11+
12+
```ts
13+
import type { JsonRenderDockRenderer } from '@devframes/json-render/hub'
14+
15+
// a hub DockRenderer narrowed to the json-render dock entry
16+
const renderer: JsonRenderDockRenderer = async ({ entry, container, context }) => {
17+
// mount your framework's root into `container`, render `entry.view`
18+
return { dispose() { /* unmount, unsubscribe */ } }
19+
}
20+
```
21+
22+
Resolve the entry's serializable `view` reference:
23+
24+
- `{ stateKey }` — subscribe to that shared state via
25+
`context.rpc.sharedState.get(stateKey)`, render its value as the live spec,
26+
and re-render on `'updated'`. **Unsubscribe in `dispose`.**
27+
- `{ spec }` — render the embedded spec directly; no shared state involved.
28+
29+
Detect static output via `context.rpc.connectionMeta.backend === 'static'` and
30+
disable action dispatch there.
31+
32+
## Behavior expectations
33+
34+
Match the reference frontend's semantics so specs behave identically across
35+
frontends:
36+
37+
- **Actions** — a spec action name dispatches an RPC call of the same name.
38+
Never bridge the reserved built-ins (`setState`, `pushState`, `removeState`,
39+
`validateForm` — handled by the upstream renderer) or promise probes
40+
(`then`/`catch`/`finally`). Surface failures to the view rather than
41+
swallowing them.
42+
- **Validation** — validate element props against `basePropSchemas` from
43+
`@devframes/json-render`; swap an invalid element for an error placeholder so
44+
one bad element doesn't break the view.
45+
- **Unknown components** — a component your registry lacks renders as a
46+
placeholder (type + prop-key gist) with a `console.warn`; the rest of the
47+
view renders.
48+
- **State reset** — reseed spec state only when the view identity changes, not
49+
on every spec update.
50+
51+
## Plugging it in
52+
53+
Two seams, one contract:
54+
55+
- **Local registration** — a host page that bundles its own client passes
56+
`createDevframeClientHost({ renderers: { 'json-render': myRenderer } })`.
57+
Local registrations win over the manifest.
58+
- **A prebuilt renderer module** — bundle your renderer as one self-contained
59+
browser ES module (framework and styles included) whose default export is the
60+
renderer, and ship a node helper returning the hub registration:
61+
62+
```ts
63+
import type { DockRendererRegistration } from '@devframes/hub/initiate'
64+
65+
export function myRenderer(): DockRendererRegistration {
66+
return { type: 'json-render', file: myPrebuiltModulePath }
67+
}
68+
```
69+
70+
Hosts compose it with `initHub({ renderers: [myRenderer()] })` — the hub
71+
serves the module and every viewer imports it lazily (see [renderer
72+
modules](./hub-initiate#renderer-modules)).
73+
74+
A prebuilt module must be **self-styling and shadow-root-safe**: the viewer's
75+
container may live inside a shadow root, so deliver your stylesheet into the
76+
mount subtree (the reference module attaches its own shadow root inside the
77+
container and injects its compiled CSS there). Read the theme from the live
78+
`dark` class the viewer keeps on the container, and derive brand color from the
79+
inherited `--devframe-primary` custom property when present.

docs/guide/client-context.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Viewers with an HTML pipeline layer injection on top: `@vitejs/devtools` wraps t
3838
| `connect` | Options forwarded to `connectDevframe` when `rpc` is not supplied — pass `baseURL` to point at the hub's connection-meta mount (e.g. `/__hub/`). |
3939
| `clientType` | `'standalone'` (default) — the runtime owns the whole page (a hub UI). `'embedded'` — the runtime lives inside a user app alongside a panel. |
4040
| `loadClientScripts` | Import and run dock entries' client scripts. Default `true`. |
41-
| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': createJsonRenderDockRenderer() }` from `@devframes/json-render-ui`). The hub ships none. |
41+
| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': createJsonRenderDockRenderer() }` from `@devframes/json-render-ui`). Local registrations take precedence over the hub's [renderer manifest](./hub-initiate#renderer-modules). |
4242

4343
Boot the host once per page: a second boot replaces the published context and logs a warning. `dispose()` tears down its listeners and unpublishes the context it owns.
4444

@@ -53,7 +53,7 @@ Boot the host once per page: a second boot replaces the published context and lo
5353
| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, plus `register()` / `update()` for [client-only docks](#client-only-docks). |
5454
| `panel` | Dock panel state: position, size, drag/resize flags. |
5555
| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. |
56-
| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a host-registered renderer (e.g. [JSON-Render](./json-render)); the hub ships none. |
56+
| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer: one registered locally at boot, or a prebuilt module lazy-imported from the hub's [renderer manifest](./hub-initiate#renderer-modules) (local wins). `mount()` resolves a typed result — `{ status: 'mounted', dispose }`, `{ status: 'missing-renderer' }`, or `{ status: 'load-error', error }` — so a viewer renders a visible fallback for a type nothing covers instead of a dead panel; `has()` answers for both sources so the fallback can render without a mount attempt. |
5757
| `when` | The [when-clause](./when-clauses) evaluation context. |
5858
| `connection` | The client's live [connection status](./client#handling-connection-and-auth-errors)`status`, `error`, and `events` — so a viewer can render one central connection indicator for every docked plugin. |
5959

docs/guide/hub-initiate.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,26 @@ interface DevframeHubUi {
6161

6262
`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself, always visible. A viewer product supplies a different object to the same slot and reuses all the infrastructure; visibility policy (keyboard summon, passive modes) belongs entirely to the entry's author.
6363

64+
## Renderer modules
65+
66+
Viewers are prebuilt, so a renderer for an opt-in dock type (e.g. [JSON-Render](./json-render)) composes at the hub, not in viewer code. `initHub({ renderers })` takes registrations — `{ type, file, importName? }`, where `file` is a prebuilt, self-contained browser ES module whose export is a ready `DockRenderer` — serves each at `<base>__renderers/<type>.mjs`, and publishes the **renderer manifest** into the `devframe:dock-renderers` shared-state slot. Any hub-aware client — the reference UI, a community viewer, a hand-rolled host page — imports a module lazily the first time a dock of its type mounts:
67+
68+
```ts
69+
import { createUi } from '@devframes/hub-ui'
70+
import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub'
71+
72+
initHub({
73+
ui: createUi(),
74+
renderers: [jsonRenderUiRenderer()],
75+
})
76+
```
77+
78+
Renderer packages ship the registration helper (here `jsonRenderUiRenderer()` resolving `@devframes/json-render-ui`'s shipped bundle); swap it for any implementation of the same renderer contract and every viewer picks the replacement up. A renderer registered directly in client code (`createDevframeClientHost({ renderers })`) takes precedence over the manifest, and a dock type covered by neither renders the viewer's missing-renderer fallback.
79+
80+
Renderer modules are **self-styling and shadow-root-safe**: a module delivers its own styles into its mount subtree (the reference module attaches its own shadow root inside the given container), the viewer keeps a live `dark` class on the container as the theme signal, and CSS custom properties (e.g. a `--devframe-primary` branding override) inherit across the boundary.
81+
82+
Registrations are validated fail-fast: one module per type (`DF8108`), an existing bundle file (`DF8109`), and a route-safe type name (`DF8110`).
83+
6484
## One Auth for the hub
6585

6686
The hub has a **single Auth**: one gate at the one shared transport covers every frame, the hub built-ins, and the MCP route. Mounted frames have no gates of their own — trust established once (OTP exchange, magic link, or a pre-shared token) unlocks the namespace. The gate is on by default; `auth: false` opts a single-user localhost setup out.

0 commit comments

Comments
 (0)