From ae7e638018ed5101c0119be5be687527aced8609 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 02:24:13 +0000 Subject: [PATCH 1/2] feat(hub): client-registered client-only docks via docks.register/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a client host contribute dock entries that live only in the page, merged with the server docks from shared state without syncing back to the hub or other viewers — mirroring the client commands.register pattern. --- docs/guide/client-context.md | 21 +++++- .../hub/src/client/__tests__/host.test.ts | 54 +++++++++++++++ packages/hub/src/client/docks.ts | 29 ++++++++ packages/hub/src/client/host.ts | 66 ++++++++++++++++++- .../@devframes/hub/client.snapshot.d.ts | 6 ++ 5 files changed, 172 insertions(+), 4 deletions(-) diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 94386ea7..f1750a2e 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -50,7 +50,7 @@ Boot the host once per page: a second boot replaces the published context and lo |----------|-------------| | `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. | | `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). | -| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. | +| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, plus `register()` / `update()` for [client-only docks](#client-only-docks). | | `panel` | Dock panel state: position, size, drag/resize flags. | | `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. | | `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. | @@ -71,6 +71,25 @@ if (ctx) { } ``` +### Client-only docks + +The [node hub context](./hub) registers docks that flow into the `devframe:docks` shared state and reach every connected viewer. A client host can also register a dock that lives only in this page, for a view a host page synthesizes itself: + +```ts +const handle = ctx.docks.register({ + id: 'my-local-view', + title: 'Local', + icon: 'ph:cube-duotone', + type: 'custom-render', + renderer: { importFrom: '/my-view.mjs' }, +}) + +handle.update({ badge: '3' }) // patch it in place (the id is immutable) +handle.dispose() // remove it +``` + +Client-only docks merge into the same `docks.entries` list, group, select, and load their client scripts exactly like server docks — they just never sync to the hub or other viewers. A client dock sharing an id with a server dock overrides it locally. `ctx.docks.update(entry)` replaces a previously registered client dock wholesale. Registering an id that a client dock already owns throws unless you pass `register(entry, true)`. + ## Dock client scripts A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind: diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 31689040..648a9f1b 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -139,6 +139,60 @@ describe('createDevframeClientHost', () => { host.dispose() }) + it('registers, updates, and disposes client-only docks merged with server entries', async () => { + const { rpc, states } = createStubRpc() + const host = await createDevframeClientHost({ rpc }) + const docks = host.context.docks + states.get('devframe:docks')!.push([iframeEntry('server')]) + + // Client-only registration is merged with the server entries. + const handle = docks.register(iframeEntry('client')) + expect(docks.entries.map(e => e.id)).toEqual(['server', 'client']) + expect(docks.getStateById('client')?.entryMeta.id).toBe('client') + + // It survives a server-driven reconcile and is switchable. + states.get('devframe:docks')!.push([iframeEntry('server'), iframeEntry('server2')]) + expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2', 'client']) + expect(await docks.switchEntry('client')).toBe(true) + expect(docks.selected?.id).toBe('client') + + // A registered client dock is never pushed into shared state (client-only). + expect((states.get('devframe:docks')!.value() as DevframeDockEntry[]).map(e => e.id)) + .toEqual(['server', 'server2']) + + // Patch in place; id is immutable. + handle.update({ title: 'Renamed' }) + expect(docks.getStateById('client')?.entryMeta.title).toBe('Renamed') + expect(() => handle.update({ id: 'other' } as any)).toThrow() + + // Duplicate id throws unless forced; update() requires a prior registration. + expect(() => docks.register(iframeEntry('client'))).toThrow() + expect(() => docks.register(iframeEntry('client'), true)).not.toThrow() + expect(() => docks.update(iframeEntry('ghost'))).toThrow() + + // Disposing removes it from the merge. + handle.dispose() + expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2']) + expect(docks.getStateById('client')).toBeUndefined() + host.dispose() + }) + + it('imports the client script of a client-registered dock', async () => { + const { rpc } = createStubRpc() + const host = await createDevframeClientHost({ rpc }) + + const received: any[] = [] + ;(globalThis as any).__DF_TEST_CLIENT_DOCK__ = (ctx: any) => received.push(ctx) + const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_CLIENT_DOCK__(ctx)` + host.context.docks.register(iframeEntry('local', { clientScript: { importFrom: dataUrl } })) + + await vi.waitFor(() => expect(received).toHaveLength(1)) + expect(received[0].current.entryMeta.id).toBe('local') + + delete (globalThis as any).__DF_TEST_CLIENT_DOCK__ + host.dispose() + }) + it('executes client commands locally and server commands over hub:commands:execute', async () => { const { rpc, calls } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index 7cbd29c2..54194dc6 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -112,6 +112,35 @@ export interface DocksEntriesContext { * @returns Whether the selection was changed successfully */ toggleEntry: (id: string) => Promise + /** + * Register a **client-only** dock entry, live in this page and merged with + * the server-provided docks (`devframe:docks` shared state) into + * {@link entries}. Unlike a dock registered on the node + * {@link import('../types/docks').DevframeDocksHost}, it never flows into + * shared state, so it stays local to this client instead of syncing to the + * hub or other viewers — for a view a client host synthesizes itself. + * + * Throws when `id` already names a client dock, unless `force` is set. A + * client dock sharing an id with a server dock overrides it in the local + * merge. Returns a handle to {@link DockRegistration.update patch} or + * {@link DockRegistration.dispose remove} it. + */ + register: (entry: T, force?: boolean) => DockRegistration + /** + * Replace a previously {@link register client-registered} dock entry, keyed + * by `id`. Throws when no client dock owns that id. + */ + update: (entry: DevframeDockUserEntry) => void +} + +export interface DockRegistration { + /** + * Patch the registered client dock in place. The `id` is immutable — passing + * a differing `id` throws. + */ + update: (patch: Partial) => void + /** Remove the client dock from the local merge. */ + dispose: () => void } export interface DockEntryState { diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 2a118493..d2ff89cf 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -98,6 +98,11 @@ export async function createDevframeClientHost( let selectedId: string | null = null const entryToStateMap = new Map() + // Docks registered live in this page via `docks.register()`. They never flow + // into the `devframe:docks` shared state (client-only), and are merged with + // the server entries — a client dock overriding a server one of the same id. + const clientDocks = new Map() + const loadScriptsEnabled = options.loadClientScripts ?? true const panel = createPanelContext(clientType) const docks = createDocksContext() @@ -172,7 +177,7 @@ export async function createDevframeClientHost( setDevframeClientContext(context) const loadedScripts = new Set() - if (options.loadClientScripts ?? true) { + if (loadScriptsEnabled) { loadClientScripts() disposers.push(docksState.on('updated', loadClientScripts)) } @@ -202,8 +207,36 @@ export async function createDevframeClientHost( } } + // The merged dock list: server entries from shared state overlaid with any + // client-registered docks (client wins on id collision, new ids appended). + function currentEntries(): DevframeDockEntry[] { + const server = docksState.value() as DevframeDockEntry[] + if (clientDocks.size === 0) + return server + const merged: DevframeDockEntry[] = [] + const seen = new Set() + for (const entry of server) { + merged.push(clientDocks.get(entry.id) ?? entry) + seen.add(entry.id) + } + for (const [id, entry] of clientDocks) { + if (!seen.has(id)) + merged.push(entry) + } + return merged + } + + // Re-run the reconcile + client-script load after a local dock mutation + // (register/update/dispose), which doesn't emit the shared-state `updated` + // event that the server path relies on. + function refreshEntries(): void { + reconcileEntries() + if (loadScriptsEnabled) + loadClientScripts() + } + function reconcileEntries(): void { - const entries = docksState.value() as DevframeDockEntry[] + const entries = currentEntries() const seen = new Set() for (const meta of entries) { @@ -246,6 +279,33 @@ export async function createDevframeClientHost( getStateById: id => entryToStateMap.get(id), switchEntry, toggleEntry: id => (selectedId === id ? switchEntry(null) : switchEntry(id)), + register(entry, force) { + if (clientDocks.has(entry.id) && !force) + throw new Error(`[@devframes/hub] a client dock "${entry.id}" is already registered — pass force to overwrite`) + clientDocks.set(entry.id, entry) + refreshEntries() + return { + update: (patch) => { + if (patch.id && patch.id !== entry.id) + throw new Error(`[@devframes/hub] cannot change a dock id ("${entry.id}" → "${patch.id}")`) + const existing = clientDocks.get(entry.id) + if (!existing) + throw new Error(`[@devframes/hub] client dock "${entry.id}" was removed — register it again to update`) + clientDocks.set(entry.id, { ...existing, ...patch } as DevframeDockEntry) + refreshEntries() + }, + dispose: () => { + if (clientDocks.delete(entry.id)) + refreshEntries() + }, + } + }, + update(entry) { + if (!clientDocks.has(entry.id)) + throw new Error(`[@devframes/hub] no client dock "${entry.id}" to update — register it first`) + clientDocks.set(entry.id, entry) + refreshEntries() + }, } return ctx } @@ -361,7 +421,7 @@ export async function createDevframeClientHost( } function loadClientScripts(): void { - for (const entry of docksState.value() as DevframeDockEntry[]) { + for (const entry of currentEntries()) { const script = clientScriptOf(entry) if (!script?.importFrom || loadedScripts.has(entry.id)) continue diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index bbea0efa..aaf444cb 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -52,6 +52,10 @@ export interface DockPanelStorage { open: boolean; inactiveTimeout: number; } +export interface DockRegistration { + update: (_: Partial) => void; + dispose: () => void; +} export interface DockRendererInstance { dispose?: () => void; } @@ -90,6 +94,8 @@ export interface DocksEntriesContext { getStateById: (_: string) => DockEntryState | undefined; switchEntry: (_?: string | null) => Promise; toggleEntry: (_: string) => Promise; + register: (_: T, _?: boolean) => DockRegistration; + update: (_: DevframeDockUserEntry) => void; } export interface DocksPanelContext { store: DockPanelStorage; From c7ef7ec72263f80f4bb32eb51cc2103f1116a688 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 02:35:08 +0000 Subject: [PATCH 2/2] docs(examples): demo client-only docks in the vite and next hubs Register a client-only "Client Notes" dock on the client host context in both minimal hub examples, source the dock list from the merged context.docks.entries, and show update()/dispose() on the returned handle. --- .../src/client/app/page.tsx | 48 ++++++++++++++++++- .../src/client/main.ts | 47 ++++++++++++++++-- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index ddde32d7..81a80ba1 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -6,6 +6,7 @@ import type { DevframeDockEntry, DevframeMessageEntry, DevframeTerminalSession, + DevframeViewIframe, } from '@devframes/hub/types' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' import { useEffect, useMemo, useRef, useState } from 'react' @@ -34,6 +35,27 @@ function isRenderableDock(d: DevframeDockEntry): boolean { return isIframeDock(d) || RENDERER_TYPES.has(d.type) } +// A self-contained document for the client-only dock, rendered from a Blob URL +// so the whole dock is synthesized in the browser with no server route. +function createClientNotesUrl(): string { + const html = ` + +

Client-only dock

+

This dock was registered in the browser with + host.context.docks.register(). It lives only in this page — it + never enters the devframe:docks shared state, so it is not synced + to the hub server or to any other connected viewer.

+

Patch it live through the returned handle with update() (its + badge was set that way), or remove it with dispose().

` + return URL.createObjectURL(new Blob([html], { type: 'text/html' })) +} + /** Render a dock icon, falling back to the title's initial when unmapped. */ function DockIcon({ entry }: { entry: DevframeDockEntry }) { const cls = iconClass(entry.icon) @@ -80,6 +102,24 @@ export default function Page() { }) hostRef.current = clientHost + // Register a *client-only* dock — one this page synthesizes itself. + // Unlike the server-authored docks, it's registered on the client host + // context, so it never enters the `devframe:docks` shared state: it + // stays local to this page and is not synced to the hub server or other + // viewers. It merges into `clientHost.context.docks.entries` alongside + // the server docks. `force` lets React StrictMode re-run this effect + // without tripping the duplicate-id guard. + const clientDock = clientHost.context.docks.register({ + id: 'client-notes', + title: 'Client Notes', + icon: 'ph:note-pencil-duotone', + type: 'iframe', + url: createClientNotesUrl(), + category: 'app', + }, true) + // Patch it in place with the returned handle (the id is immutable). + clientDock.update({ badge: clientHost.context.clientType }) + const docksState = await rpc.sharedState.get( 'devframe:docks', { initialValue: [] }, @@ -89,7 +129,10 @@ export default function Page() { { initialValue: [] }, ) - const renderDocks = () => setDocks([...(docksState.value() ?? [])] as DevframeDockEntry[]) + // The merged list from the client host: server docks (projected from + // `devframe:docks` shared state) plus the client-only dock above. We + // still subscribe to the shared state to re-render on server changes. + const renderDocks = () => setDocks([...clientHost.context.docks.entries]) const renderCommands = () => setCommands([...(commandsState.value() ?? [])] as DevframeCommandEntry[]) docksState.on('updated', renderDocks) commandsState.on('updated', renderCommands) @@ -122,6 +165,8 @@ export default function Page() { cleanup = () => { window.clearInterval(interval) + // Remove the client-only dock, then tear down the host. + clientDock.dispose() clientHost.dispose() } } @@ -224,6 +269,7 @@ export default function Page() { > {dock.title} + {dock.badge && {dock.badge}} ))} diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index f4e6afc4..72b2655a 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -3,6 +3,7 @@ import type { DevframeDockEntry, DevframeMessageEntry, DevframeTerminalSession, + DevframeViewIframe, } from '@devframes/hub/types' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' @@ -58,6 +59,27 @@ function isRenderableDock(d: DevframeDockEntry): boolean { return isIframeDock(d) || RENDERER_TYPES.has(d.type) } +// A self-contained document for the client-only dock, rendered from a Blob URL +// so the whole dock is synthesized in the browser with no server route. +function createClientNotesUrl(): string { + const html = ` + +

Client-only dock

+

This dock was registered in the browser with + host.context.docks.register(). It lives only in this page — it + never enters the devframe:docks shared state, so it is not synced + to the hub server or to any other connected viewer.

+

Patch it live through the returned handle with update() (its + badge was set that way), or remove it with dispose().

` + return URL.createObjectURL(new Blob([html], { type: 'text/html' })) +} + async function main() { setStatus('Connecting…') @@ -76,7 +98,26 @@ async function main() { renderers: { 'json-render': createJsonRenderDockRenderer() }, }) - // 1. Docks — read from `devframe:docks` shared state. + // Register a *client-only* dock — one this page synthesizes itself. Unlike + // the server-authored docks, it's registered on the client host context, so + // it never enters the `devframe:docks` shared state: it stays local to this + // page and is not synced to the hub server or other viewers. It merges into + // `host.context.docks.entries` (read below) alongside the server docks. + const clientDock = host.context.docks.register({ + id: 'client-notes', + title: 'Client Notes', + icon: 'ph:note-pencil-duotone', + type: 'iframe', + url: createClientNotesUrl(), + category: 'app', + }) + // Patch it in place with the returned handle (the id is immutable). Call + // `clientDock.dispose()` to remove it from the merged list again. + clientDock.update({ badge: host.context.clientType }) + + // 1. Docks — the merged list from the client host: server docks (projected + // from `devframe:docks` shared state) plus the client-only dock above. We + // still subscribe to the shared state to re-render when server docks change. const docks = await rpc.sharedState.get( 'devframe:docks', { initialValue: [] }, @@ -119,7 +160,7 @@ async function main() { } const renderDocks = () => { - const list = (docks.value() ?? []).filter(isRenderableDock) + const list = host.context.docks.entries.filter(isRenderableDock) if (selectedDockId && !list.some(d => d.id === selectedDockId)) selectedDockId = null @@ -136,7 +177,7 @@ async function main() { } renderList(docksEl, list, d => - `
  • `) + `
  • `) void applySelection(list) }