Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/guide/client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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:
Expand Down
48 changes: 47 additions & 1 deletion examples/minimal-next-devframe-hub/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = `<!doctype html><meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
body { margin: 0; padding: 24px; font: 14px/1.6 system-ui, sans-serif; }
h1 { margin: 0 0 8px; font-size: 16px; }
p { max-width: 54ch; opacity: .85; }
code { padding: 1px 5px; border-radius: 4px; background: rgba(127,127,127,.18); font-size: 12px; }
</style>
<h1>Client-only dock</h1>
<p>This dock was registered in the browser with
<code>host.context.docks.register()</code>. It lives only in this page — it
never enters the <code>devframe:docks</code> shared state, so it is not synced
to the hub server or to any other connected viewer.</p>
<p>Patch it live through the returned handle with <code>update()</code> (its
<code>badge</code> was set that way), or remove it with <code>dispose()</code>.</p>`
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)
Expand Down Expand Up @@ -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<DevframeViewIframe>({
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<DevframeDockEntry[]>(
'devframe:docks',
{ initialValue: [] },
Expand All @@ -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)
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -224,6 +269,7 @@ export default function Page() {
>
<DockIcon entry={dock} />
<span className="truncate">{dock.title}</span>
{dock.badge && <span className="ml-auto shrink-0 rounded bg-active px1 py0.5 text-[0.6rem] font-mono color-base">{dock.badge}</span>}
</button>
</li>
))}
Expand Down
47 changes: 44 additions & 3 deletions examples/minimal-vite-devframe-hub/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = `<!doctype html><meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
body { margin: 0; padding: 24px; font: 14px/1.6 system-ui, sans-serif; }
h1 { margin: 0 0 8px; font-size: 16px; }
p { max-width: 54ch; opacity: .85; }
code { padding: 1px 5px; border-radius: 4px; background: rgba(127,127,127,.18); font-size: 12px; }
</style>
<h1>Client-only dock</h1>
<p>This dock was registered in the browser with
<code>host.context.docks.register()</code>. It lives only in this page — it
never enters the <code>devframe:docks</code> shared state, so it is not synced
to the hub server or to any other connected viewer.</p>
<p>Patch it live through the returned handle with <code>update()</code> (its
<code>badge</code> was set that way), or remove it with <code>dispose()</code>.</p>`
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
}

async function main() {
setStatus('Connecting…')

Expand All @@ -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<DevframeViewIframe>({
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<DevframeDockEntry[]>(
'devframe:docks',
{ initialValue: [] },
Expand Down Expand Up @@ -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
Expand All @@ -136,7 +177,7 @@ async function main() {
}

renderList(docksEl, list, d =>
`<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>`)
`<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>`)

void applySelection(list)
}
Expand Down
54 changes: 54 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
29 changes: 29 additions & 0 deletions packages/hub/src/client/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,35 @@ export interface DocksEntriesContext {
* @returns Whether the selection was changed successfully
*/
toggleEntry: (id: string) => Promise<boolean>
/**
* 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: <T extends DevframeDockEntry>(entry: T, force?: boolean) => DockRegistration<T>
/**
* 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<T extends DevframeDockEntry = DevframeDockEntry> {
/**
* Patch the registered client dock in place. The `id` is immutable — passing
* a differing `id` throws.
*/
update: (patch: Partial<T>) => void
/** Remove the client dock from the local merge. */
dispose: () => void
}

export interface DockEntryState {
Expand Down
Loading
Loading