diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md
index 94386ea..f1750a2 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/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx
index ddde32d..81a80ba 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 f4e6afc..72b2655 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)
}
diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts
index 3168904..648a9f1 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 7cbd29c..54194dc 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 2a11849..d2ff89c 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 bbea0ef..aaf444c 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;