Skip to content

Commit caf318b

Browse files
authored
feat(hub): client-registered client-only docks via docks.register/update (#129)
1 parent 6ee61dd commit caf318b

7 files changed

Lines changed: 263 additions & 8 deletions

File tree

‎docs/guide/client-context.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Boot the host once per page: a second boot replaces the published context and lo
5050
|----------|-------------|
5151
| `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. |
5252
| `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). |
53-
| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. |
53+
| `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()`. |
5656
| `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) {
7171
}
7272
```
7373

74+
### Client-only docks
75+
76+
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:
77+
78+
```ts
79+
const handle = ctx.docks.register({
80+
id: 'my-local-view',
81+
title: 'Local',
82+
icon: 'ph:cube-duotone',
83+
type: 'custom-render',
84+
renderer: { importFrom: '/my-view.mjs' },
85+
})
86+
87+
handle.update({ badge: '3' }) // patch it in place (the id is immutable)
88+
handle.dispose() // remove it
89+
```
90+
91+
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)`.
92+
7493
## Dock client scripts
7594

7695
A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind:

‎examples/minimal-next-devframe-hub/src/client/app/page.tsx‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
DevframeDockEntry,
77
DevframeMessageEntry,
88
DevframeTerminalSession,
9+
DevframeViewIframe,
910
} from '@devframes/hub/types'
1011
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
1112
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -34,6 +35,27 @@ function isRenderableDock(d: DevframeDockEntry): boolean {
3435
return isIframeDock(d) || RENDERER_TYPES.has(d.type)
3536
}
3637

38+
// A self-contained document for the client-only dock, rendered from a Blob URL
39+
// so the whole dock is synthesized in the browser with no server route.
40+
function createClientNotesUrl(): string {
41+
const html = `<!doctype html><meta charset="utf-8">
42+
<style>
43+
:root { color-scheme: light dark; }
44+
body { margin: 0; padding: 24px; font: 14px/1.6 system-ui, sans-serif; }
45+
h1 { margin: 0 0 8px; font-size: 16px; }
46+
p { max-width: 54ch; opacity: .85; }
47+
code { padding: 1px 5px; border-radius: 4px; background: rgba(127,127,127,.18); font-size: 12px; }
48+
</style>
49+
<h1>Client-only dock</h1>
50+
<p>This dock was registered in the browser with
51+
<code>host.context.docks.register()</code>. It lives only in this page — it
52+
never enters the <code>devframe:docks</code> shared state, so it is not synced
53+
to the hub server or to any other connected viewer.</p>
54+
<p>Patch it live through the returned handle with <code>update()</code> (its
55+
<code>badge</code> was set that way), or remove it with <code>dispose()</code>.</p>`
56+
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
57+
}
58+
3759
/** Render a dock icon, falling back to the title's initial when unmapped. */
3860
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
3961
const cls = iconClass(entry.icon)
@@ -80,6 +102,24 @@ export default function Page() {
80102
})
81103
hostRef.current = clientHost
82104

105+
// Register a *client-only* dock — one this page synthesizes itself.
106+
// Unlike the server-authored docks, it's registered on the client host
107+
// context, so it never enters the `devframe:docks` shared state: it
108+
// stays local to this page and is not synced to the hub server or other
109+
// viewers. It merges into `clientHost.context.docks.entries` alongside
110+
// the server docks. `force` lets React StrictMode re-run this effect
111+
// without tripping the duplicate-id guard.
112+
const clientDock = clientHost.context.docks.register<DevframeViewIframe>({
113+
id: 'client-notes',
114+
title: 'Client Notes',
115+
icon: 'ph:note-pencil-duotone',
116+
type: 'iframe',
117+
url: createClientNotesUrl(),
118+
category: 'app',
119+
}, true)
120+
// Patch it in place with the returned handle (the id is immutable).
121+
clientDock.update({ badge: clientHost.context.clientType })
122+
83123
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
84124
'devframe:docks',
85125
{ initialValue: [] },
@@ -89,7 +129,10 @@ export default function Page() {
89129
{ initialValue: [] },
90130
)
91131

92-
const renderDocks = () => setDocks([...(docksState.value() ?? [])] as DevframeDockEntry[])
132+
// The merged list from the client host: server docks (projected from
133+
// `devframe:docks` shared state) plus the client-only dock above. We
134+
// still subscribe to the shared state to re-render on server changes.
135+
const renderDocks = () => setDocks([...clientHost.context.docks.entries])
93136
const renderCommands = () => setCommands([...(commandsState.value() ?? [])] as DevframeCommandEntry[])
94137
docksState.on('updated', renderDocks)
95138
commandsState.on('updated', renderCommands)
@@ -122,6 +165,8 @@ export default function Page() {
122165

123166
cleanup = () => {
124167
window.clearInterval(interval)
168+
// Remove the client-only dock, then tear down the host.
169+
clientDock.dispose()
125170
clientHost.dispose()
126171
}
127172
}
@@ -224,6 +269,7 @@ export default function Page() {
224269
>
225270
<DockIcon entry={dock} />
226271
<span className="truncate">{dock.title}</span>
272+
{dock.badge && <span className="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">{dock.badge}</span>}
227273
</button>
228274
</li>
229275
))}

‎examples/minimal-vite-devframe-hub/src/client/main.ts‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
DevframeDockEntry,
44
DevframeMessageEntry,
55
DevframeTerminalSession,
6+
DevframeViewIframe,
67
} from '@devframes/hub/types'
78
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
89
import { createJsonRenderDockRenderer } from '@devframes/json-render-ui'
@@ -58,6 +59,27 @@ function isRenderableDock(d: DevframeDockEntry): boolean {
5859
return isIframeDock(d) || RENDERER_TYPES.has(d.type)
5960
}
6061

62+
// A self-contained document for the client-only dock, rendered from a Blob URL
63+
// so the whole dock is synthesized in the browser with no server route.
64+
function createClientNotesUrl(): string {
65+
const html = `<!doctype html><meta charset="utf-8">
66+
<style>
67+
:root { color-scheme: light dark; }
68+
body { margin: 0; padding: 24px; font: 14px/1.6 system-ui, sans-serif; }
69+
h1 { margin: 0 0 8px; font-size: 16px; }
70+
p { max-width: 54ch; opacity: .85; }
71+
code { padding: 1px 5px; border-radius: 4px; background: rgba(127,127,127,.18); font-size: 12px; }
72+
</style>
73+
<h1>Client-only dock</h1>
74+
<p>This dock was registered in the browser with
75+
<code>host.context.docks.register()</code>. It lives only in this page — it
76+
never enters the <code>devframe:docks</code> shared state, so it is not synced
77+
to the hub server or to any other connected viewer.</p>
78+
<p>Patch it live through the returned handle with <code>update()</code> (its
79+
<code>badge</code> was set that way), or remove it with <code>dispose()</code>.</p>`
80+
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
81+
}
82+
6183
async function main() {
6284
setStatus('Connecting…')
6385

@@ -76,7 +98,26 @@ async function main() {
7698
renderers: { 'json-render': createJsonRenderDockRenderer() },
7799
})
78100

79-
// 1. Docks — read from `devframe:docks` shared state.
101+
// Register a *client-only* dock — one this page synthesizes itself. Unlike
102+
// the server-authored docks, it's registered on the client host context, so
103+
// it never enters the `devframe:docks` shared state: it stays local to this
104+
// page and is not synced to the hub server or other viewers. It merges into
105+
// `host.context.docks.entries` (read below) alongside the server docks.
106+
const clientDock = host.context.docks.register<DevframeViewIframe>({
107+
id: 'client-notes',
108+
title: 'Client Notes',
109+
icon: 'ph:note-pencil-duotone',
110+
type: 'iframe',
111+
url: createClientNotesUrl(),
112+
category: 'app',
113+
})
114+
// Patch it in place with the returned handle (the id is immutable). Call
115+
// `clientDock.dispose()` to remove it from the merged list again.
116+
clientDock.update({ badge: host.context.clientType })
117+
118+
// 1. Docks — the merged list from the client host: server docks (projected
119+
// from `devframe:docks` shared state) plus the client-only dock above. We
120+
// still subscribe to the shared state to re-render when server docks change.
80121
const docks = await rpc.sharedState.get<DevframeDockEntry[]>(
81122
'devframe:docks',
82123
{ initialValue: [] },
@@ -119,7 +160,7 @@ async function main() {
119160
}
120161

121162
const renderDocks = () => {
122-
const list = (docks.value() ?? []).filter(isRenderableDock)
163+
const list = host.context.docks.entries.filter(isRenderableDock)
123164

124165
if (selectedDockId && !list.some(d => d.id === selectedDockId))
125166
selectedDockId = null
@@ -136,7 +177,7 @@ async function main() {
136177
}
137178

138179
renderList(docksEl, list, d =>
139-
`<li><button type="button" data-dock-id="${d.id}" class="relative inline-flex items-center gap-1.5 max-w-52 px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition hover:op100 hover:bg-active w-full! max-w-none! gap-2.5!${d.id === selectedDockId ? ' op100! bg-active border-base! color-base' : ''}" title="${d.title}">${dockIcon(d)}<span class="truncate">${d.title}</span></button></li>`)
180+
`<li><button type="button" data-dock-id="${d.id}" class="relative inline-flex items-center gap-1.5 max-w-52 px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition hover:op100 hover:bg-active w-full! max-w-none! gap-2.5!${d.id === selectedDockId ? ' op100! bg-active border-base! color-base' : ''}" title="${d.title}">${dockIcon(d)}<span class="truncate">${d.title}</span>${d.badge ? `<span class="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">${d.badge}</span>` : ''}</button></li>`)
140181

141182
void applySelection(list)
142183
}

‎packages/hub/src/client/__tests__/host.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,60 @@ describe('createDevframeClientHost', () => {
139139
host.dispose()
140140
})
141141

142+
it('registers, updates, and disposes client-only docks merged with server entries', async () => {
143+
const { rpc, states } = createStubRpc()
144+
const host = await createDevframeClientHost({ rpc })
145+
const docks = host.context.docks
146+
states.get('devframe:docks')!.push([iframeEntry('server')])
147+
148+
// Client-only registration is merged with the server entries.
149+
const handle = docks.register(iframeEntry('client'))
150+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'client'])
151+
expect(docks.getStateById('client')?.entryMeta.id).toBe('client')
152+
153+
// It survives a server-driven reconcile and is switchable.
154+
states.get('devframe:docks')!.push([iframeEntry('server'), iframeEntry('server2')])
155+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2', 'client'])
156+
expect(await docks.switchEntry('client')).toBe(true)
157+
expect(docks.selected?.id).toBe('client')
158+
159+
// A registered client dock is never pushed into shared state (client-only).
160+
expect((states.get('devframe:docks')!.value() as DevframeDockEntry[]).map(e => e.id))
161+
.toEqual(['server', 'server2'])
162+
163+
// Patch in place; id is immutable.
164+
handle.update({ title: 'Renamed' })
165+
expect(docks.getStateById('client')?.entryMeta.title).toBe('Renamed')
166+
expect(() => handle.update({ id: 'other' } as any)).toThrow()
167+
168+
// Duplicate id throws unless forced; update() requires a prior registration.
169+
expect(() => docks.register(iframeEntry('client'))).toThrow()
170+
expect(() => docks.register(iframeEntry('client'), true)).not.toThrow()
171+
expect(() => docks.update(iframeEntry('ghost'))).toThrow()
172+
173+
// Disposing removes it from the merge.
174+
handle.dispose()
175+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2'])
176+
expect(docks.getStateById('client')).toBeUndefined()
177+
host.dispose()
178+
})
179+
180+
it('imports the client script of a client-registered dock', async () => {
181+
const { rpc } = createStubRpc()
182+
const host = await createDevframeClientHost({ rpc })
183+
184+
const received: any[] = []
185+
;(globalThis as any).__DF_TEST_CLIENT_DOCK__ = (ctx: any) => received.push(ctx)
186+
const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_CLIENT_DOCK__(ctx)`
187+
host.context.docks.register(iframeEntry('local', { clientScript: { importFrom: dataUrl } }))
188+
189+
await vi.waitFor(() => expect(received).toHaveLength(1))
190+
expect(received[0].current.entryMeta.id).toBe('local')
191+
192+
delete (globalThis as any).__DF_TEST_CLIENT_DOCK__
193+
host.dispose()
194+
})
195+
142196
it('executes client commands locally and server commands over hub:commands:execute', async () => {
143197
const { rpc, calls } = createStubRpc()
144198
const host = await createDevframeClientHost({ rpc })

‎packages/hub/src/client/docks.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,35 @@ export interface DocksEntriesContext {
112112
* @returns Whether the selection was changed successfully
113113
*/
114114
toggleEntry: (id: string) => Promise<boolean>
115+
/**
116+
* Register a **client-only** dock entry, live in this page and merged with
117+
* the server-provided docks (`devframe:docks` shared state) into
118+
* {@link entries}. Unlike a dock registered on the node
119+
* {@link import('../types/docks').DevframeDocksHost}, it never flows into
120+
* shared state, so it stays local to this client instead of syncing to the
121+
* hub or other viewers — for a view a client host synthesizes itself.
122+
*
123+
* Throws when `id` already names a client dock, unless `force` is set. A
124+
* client dock sharing an id with a server dock overrides it in the local
125+
* merge. Returns a handle to {@link DockRegistration.update patch} or
126+
* {@link DockRegistration.dispose remove} it.
127+
*/
128+
register: <T extends DevframeDockEntry>(entry: T, force?: boolean) => DockRegistration<T>
129+
/**
130+
* Replace a previously {@link register client-registered} dock entry, keyed
131+
* by `id`. Throws when no client dock owns that id.
132+
*/
133+
update: (entry: DevframeDockUserEntry) => void
134+
}
135+
136+
export interface DockRegistration<T extends DevframeDockEntry = DevframeDockEntry> {
137+
/**
138+
* Patch the registered client dock in place. The `id` is immutable — passing
139+
* a differing `id` throws.
140+
*/
141+
update: (patch: Partial<T>) => void
142+
/** Remove the client dock from the local merge. */
143+
dispose: () => void
115144
}
116145

117146
export interface DockEntryState {

0 commit comments

Comments
 (0)